Skip to main content

Keeper

Keeper is Crewship’s AI-powered security gatekeeper that evaluates credential access requests from agents. It runs a local LLM (via Ollama) to decide whether an agent should be allowed to access a credential, without sending sensitive data to external services.

Architecture

Credential tiers (L1–L4)

Every credential carries a tier. It is the single most important thing to get right about a credential, because it decides what happens when an agent asks for it — not just what the request is labelled. Set it when you create the credential (the tier picker is on the last step of the add flow) or afterwards:
Until #1530 the tier was a label with almost nothing behind it: L2, L3 and L4 reached the judge with an identical prompt and an identical decision space, so the only difference between an npm token and a production database admin was one line of text the model was free to ignore. Worse, security_level: 4 fell outside the API’s 1..3 range check and was silently stored as 1 — marking a credential critical filed it at the lowest tier and returned 201. If you set tiers before that release, re-check them: crewship credential list now prints the tier each one actually has.
A tier can only tighten a verdict, never loosen one. A judge that denies an L1 read still denies it; the floor adds strictness on top of the model’s answer and never subtracts. And a credential whose level is corrupt or from a future release is treated as L4, not L1 — an unknown blast radius reads as the worst case, or a garbage value would be the cheapest bypass in the system.

What the tier tells the judge

The tier is not a number in the prompt any more. Each one injects an authoritative block naming the tier, describing what a credential at that tier can do, and listing the questions to ask at that level — “is this the narrowest credential that can do the stated job”, “does the conversation independently corroborate that this work is underway”, and at L4 “would you be comfortable defending this grant in an incident review”. Lower tiers deliberately do not get the higher tiers’ questions: interrogating every npm token read about production blast radius is how a judge learns to ignore the block.

Thin intents are refused without a model call

At L3 and above, an intent shorter than the tier’s minimum is denied before the model is called. Two reasons, and the second is the one that matters: a model call for "need db access" on a production-admin credential is money spent to reach a foregone conclusion, and the refusal we write ourselves can say what would work —
L3 · high credential (administrative access to real infrastructure…): the stated intent is 14 characters, and this tier needs at least 25. Say what the credential is for, on what system, and why this one — a longer restatement of the credential’s name will be denied again.
which is the difference between an agent that retries with a real justification and one that retries with the same four words.

L1 Auto-Allow Fast Path

For L1 credentials, Keeper skips the LLM entirely when:
  1. The request is a credential-access flow (RequestType empty or access) — F4.x request types always reach the LLM
  2. The security level is L1
  3. The intent string has at least 10 non-whitespace characters
  4. The intent contains at least 5 distinct non-whitespace characters (blocks trivial filler like "aaaaaaaaaa" or "aaabbbcccddd")
  5. The intent doesn’t look like a prompt injection (looksLikeIntentInjection — e.g. no ignore previous, system:, or a JSON {...} brace pair)
  6. The request is NOT a /keeper/execute request
L1 auto-allow never applies to /keeper/execute requests. The Command field must always be evaluated by the LLM to prevent credential exfiltration attacks like echo $TOKEN | base64 that bypass output scrubbing.

LLM Evaluation

For L2+ credentials (and L1 execute requests), Keeper sends a structured prompt to the local LLM.

Prompt Injection Defense

Keeper uses random delimiters around conversation history to prevent prompt injection. An 8-byte random value (16 hex characters) wraps the history block, making it extremely difficult for an injected payload to close the delimiter and hijack the prompt.
If the random delimiter fails (entropy unavailable), the conversation history is skipped entirely rather than included without protection.

Response Parsing

The LLM response is parsed for a JSON object. Defensive measures:
  1. Scan for the first { and last } to extract JSON
  2. Normalize decision to uppercase
  3. Unknown decisions default to DENY (fail closed)
  4. Risk scores clamped to [1, 10]
  5. If parsing fails entirely, the request is DENYed by default

Fail-Closed Design

Keeper follows a strict fail-closed philosophy:

The Execute Flow

The /keeper/execute endpoint allows agents to run shell commands with credentials injected as environment variables. This is the most security-sensitive path:
Execute only works with type: SECRET credentials. The Keeper secrets store loads exclusively type='SECRET' rows (internal/keeper/secrets/store.go) — an execute request naming an API_KEY, CLI_TOKEN, or any other type fails with “credential not available in secrets store” even after an ALLOW decision. Non-SECRET credentials reach agents through their normal delivery paths (env vars, /secrets/ files) instead.
Credential leases (TTL) apply on every delivery path. A lapsed grant (see Short-lived leases) is refused whichever path would inject it — /keeper/execute, agent boot, a peer query, or delegation — because all four share one gate: expires_at IS NULL OR expires_at > now. For boot-delivered provider keys (API_KEY / AI_CLI_TOKEN, e.g. the Anthropic key) the deadline additionally travels with the credential into the crew sidecar’s store, which refuses it the moment it lapses and evicts it from memory within one reaper interval (~60s). A leased provider key does not survive for the container’s whole lifetime.

Approval issues a lease

By default an ALLOW is a one-off verdict: the underlying grant stays standing, so the agent keeps the access it was given indefinitely. Set a workspace auto-lease TTL and the approval itself becomes the thing that grants access, for that long only:
Every ALLOW on an L3/L4 credential — at /keeper/request or /keeper/execute — then re-issues the requesting agent’s grant as a 15-minute lease, and each subsequent ALLOW refreshes it. Approving an agent-proposed CREDENTIAL escalation does the same. When the agent stops asking, the access lapses on its own. Opt-in, L3/L4 only, never shortens a longer hand-set lease, and audited as a LEASED credential event plus a credential.lease_issued journal entry. Full rules and rails: Auto-issued leases.
1

Agent sends execute request

(DB_PASSWORD is a type: SECRET credential — see the Note above.)
2

Sidecar validates

  • Checks intent and command length limits (4096 chars each)
  • Rejects null bytes (binary injection)
  • Rejects dangerous shell operators: ;, |, `, >, &&, ||, $(
  • Content inside single quotes is exempt (shell does not interpret)
  • Sets container_id from IPC config (agents cannot override)
3

crewshipd evaluates via Keeper LLM

The command is included in the prompt for full LLM review.
4

If ALLOW, execute command

The credential is injected as an environment variable, the command runs inside the container as the container’s resolved run-as user (never hardcoded; the exec is refused if that user is undeterminable or root), and the output is scrubbed of credential values before being returned to the agent.

Shell Injection Protection

The containsDangerousShellChars function in internal/sidecar/keeper_bridge.go blocks: Content inside single quotes is allowed — the shell does not interpret special characters within single quotes.

The admin page, in the order it asks

Deep-linkable, like Settings: /admin?tab=security is the Keeper page and /admin?tab=reviews is the decision queue. Paste either into a ticket or a runbook and it lands where you meant. Four cards, each answering one question, top to bottom: The split between the first two is real and not cosmetic: the first is the instance default and accepts a native Ollama endpoint only, the second overrides it for one workspace and is where a hosted judge lives. On a single-workspace instance you will only ever touch the first — unless you want a hosted judge, which is workspace-scoped because the API key lives in that workspace’s vault. Both cards say so on themselves; Two judge scopes is the long form.
Several Anthropic keys is the normal case on an orchestration platform: each carries its own subscription limit. Store them as separate credentials with meaningful names, then pick one by name — and press Test, which tells you whether that particular key still answers. A 401 is the wrong key and a 429 is a spent subscription; both used to be invisible until a credential request denied.

Configuration

Enable Keeper in your config:
Or via environment variables:
Setting KEEPER_OLLAMA_URL auto-enables Keeper unless KEEPER_ENABLED is explicitly set to false. There is no default model — an enabled Keeper with an empty keeper.model (and no KEEPER_MODEL) fails validation at startup with keeper.enabled=true but keeper.model is empty. The earlier silent phi3:mini fallback was removed (PR-Z).

Rule on an escalation

An escalation exists so a person decides. crewship inbox list shows the ones waiting; this is how you answer one:
The same decision is one click in the inbox reading pane (Approve / Deny), and both go to POST /api/v1/admin/keeper/requests/{requestId}/resolve. Four things worth knowing before you use it:
  • OWNER or ADMIN only. The inbox item is addressed at the same tier, so the people who see it are the people who can act on it.
  • Only allow or deny. This command is the escalation being answered; there is nothing left to escalate to.
  • Once. A settled request answers 409. Your ruling is what keeper eval later reads as ground truth, and a verdict that can be rewritten is not one — including when two admins answer the same request at the same moment.
  • Four-eyes may refuse you. See Watchdog Governance — a refusal there is not about your role.
Your ruling lands in three places: the request’s decision, the inbox item (resolved, stamped with your user id), and the append-only ledger — crewship keeper history <request-id> shows it as the final transition, with user as the actor, so the audit trail records that a person decided rather than the model.

Ask the judge yourself

keeper judge test proves the judge answers, but it asks one fixed scenario. This asks yours — your credential, your tier, your wording:
It travels the same path an agent’s request does: the tier floors apply, the decision lands on the audit trail, an escalation reaches the inbox, and the health window records it. So the verdict you see is the verdict an agent would have got. Two uses beyond curiosity. Tuning — change the judge profile, re-ask, and compare; the profile in force is stamped on each decision, so keeper requests tells you which regime produced which answer. Ground truthkeeper eval needs about twenty human-ruled decisions before it will quote a rate, and this is how you produce varied cases to rule on instead of waiting for them to happen.

Local judge or hosted? Measure it

keeper eval scores candidates against decisions a person ruled on, and a candidate may name a hosted provider:
A bare name is Ollama; anthropic/ and openai/ prefixes are hosted, and the key comes from your own environment (ANTHROPIC_API_KEY or OPENAI_API_KEY) rather than the vault — this command reads a local database and dials from your machine. Two things the numbers will not tell you, so decide them yourself:
  • Keeper is fail-closed. If the judge is unreachable, every credential request is denied. A hosted judge makes that dependent on somebody else’s network, an expired key or a rate limit; a local one depends on a machine you own. A strict judge causes friction, an unreachable one causes an outage.
  • The prompt carries the agent’s conversation. It never carries a credential’s value, and secrets matching the scrubber’s patterns are redacted before it is built — but the surrounding conversation is still your agents’ work, and a hosted judge means it leaves the machine.

Checking how the judge has been deciding

Keeper is fail-closed, so a broken judge and a strict one produce the same response: DENY, in the right format, with a plausible reason. That is how crewship#1624 ran for milestones — nothing about a fail-closed denial looks different from a considered one. The decision monitor watches for it and raises an inbox item when the picture collapses. To look without waiting to be paged:
Read the “got somewhere” line, not the allow rate. A workspace whose credentials are all L4 escalates every request by design and sits at an allow rate of exactly zero while working perfectly — which is why the alarm reads the granted-or-escalated share and not ALLOW alone. The window lives in memory, so it empties on restart. An empty window is not a claim that the judge is healthy, and the command says so rather than printing reassuring zeroes. Below the alarm’s minimum sample count it also says the rates are a hint rather than a measurement. Exits non-zero while an alarm is standing, so it works in a cron. Same data over HTTP at GET /api/v1/admin/keeper/health (ADMIN+).

Where to set this in the product

Admin → Keeper → Judge profile, directly beneath the card that names the model. That card answers what decides; the profile answers under what rules. Every row shows where its value came from — a built-in default, the preset you picked, or an override somebody set here — because “off by default” and “somebody turned this off” look identical without it. Saving sends only what you changed, so it will not overwrite a setting made from the CLI or by another admin. Two switches the CLI accepts are deliberately absent from the page: precedent and consistency-samples are stored but not yet implemented, and a control that does nothing is a promise the product does not keep.

crewship seed brings the watchdog up with it

A demo seed configures the Keeper as one of its phases, so a fresh instance has a working gate rather than a feature you still have to find. It reads KEEPER_OLLAMA_URL and KEEPER_MODEL from the environment you run crewship seed in (.env.local counts), pins them as the instance judge, and then runs the same four-stage check as crewship keeper judge test. The watchdog is enabled only if that check passes. This is the important part: Keeper is fail-closed, so a watchdog switched on against an endpoint that does not answer denies every credential request, and each denial looks exactly like a considered verdict. When the check fails the seed prints the failing stage and leaves the watchdog off:
With no KEEPER_OLLAMA_URL set, the phase is skipped entirely and the instance comes up exactly as it did before. KEEPER_MODEL defaults to qwen3.5:9b.

Putting a human on L3

The step from L3 to L4 is the largest trust jump in the product. L3 is administrative access to real infrastructure — SSH, database admin, cloud account and the model grants it alone; L4 is the first tier a person always confirms. On a local 9B judge that is a lot of authority resting on one verdict. Until now the only way to put a human in front of an L3 credential was to relabel it L4 — which also imposes the four-eyes rule and L4’s 35-character intent minimum, whether you wanted those or not.
A judge ALLOW on an L3 credential now comes back as ESCALATE and lands in the inbox. Nothing else about L3 changes: same intent minimum, same risk floor, same four-eyes setting. A floor at L3 leaves L1 and L2 exactly as they were, including L1’s no-model fast path. Nothing else about L3 moves either.

…and taking the human out entirely

The dial goes the other way too:
5 means never: no tier is escalated on the model’s behalf, L4 included. An agent that satisfies the judge gets production admin without a person in the loop. That is a real choice and it is yours to make — this is your instance and your credentials, and a product that refused would not be safer, it would be wrong about whose decision this is. Two things are worth knowing before you make it:
  • The default is the protection. An instance nobody configures escalates every L4 read. You have to ask for autonomy; you cannot arrive at it by accident, and an out-of-range value falls back to the tier table rather than guessing.
  • It does not turn the Keeper off. A DENY is still a DENY, the per-tier intent minimums still refuse a thin justification before the model is asked, the hard gate still applies, and every decision is still on the audit trail. escalate-from 5 removes the human confirmation step, not the gate.
crewship keeper profile get prints this one in red, because “never” is the only setting on the page that removes a person from a production credential.
This is the dial to reach for before moving to a bigger judge. A model that is right most of the time plus a person on the tier that matters is usually a better trade than a model that is right slightly more often and unsupervised.

The prompt budget, and why it protects the policy first

The conversation history the judge sees is bounded in message count, not in tokens. A model server truncates from the front when a prompt overruns its context, and the judge’s prompt is assembled in this order:
So the first thing a long conversation pushes out is the operator’s watch policy and the credential’s tier. The judge keeps answering, in the right format, having never been told the rules it was meant to apply — and nothing in the response distinguishes that from a considered verdict. prompt_budget_tokens moves the decision to where the order is known. The policy, the tier, the facts and the request itself are incompressible; the budget is spent on the conversation, trimmed from the front so the most recent messages survive, and the cut is disclosed inside the prompt:
That notice is not decoration. The decision criteria ask the judge whether the conversation supports the request, so a silent cut turns “I was not shown it” into “it did not happen” — a manufactured refusal.
Set it to your judge’s num_ctx minus the reply budget. On the reference deployment (qwen3.5:9b, num_ctx 4096, 256-token verdict) around 3500 is right. Leaving it at 0 keeps the pre-existing behaviour: no cap, and the model server decides what to drop.

Reasoning models: the judge turns thinking off

The judge asks for one JSON verdict in a 256-token budget and is fail-closed. A reasoning model — Qwen 3.x, DeepSeek-R1, gpt-oss — spends that entire budget on its chain of thought and returns an empty answer, which the judge can only read as a DENY. Measured on qwen3.5:9b: 1063 characters of reasoning, no verdict. Crewship therefore sends Ollama think: false on the judge call, and a thinking model answers normally — the same request came back as a parseable verdict in 45 tokens and 3.4s. You do not need to pick a non-reasoning model, and you do not need a custom Modelfile. Two things follow from this:
  • crewship keeper judge test exercises the real call, thinking suppressed, so its stage-3 verdict is what production will get. Testing the same model by hand with ollama run without --think=false will look broken when it is not.
  • The flag is judge-scoped. Background evaluators and chat are unaffected, and models with no thinking capability never see the field.
Sizing, if you are running the judge on a laptop: qwen3.5:9b at Q4_K_M is ~5.6GB resident and answers in ~3.4s on an M4 MacBook Air with 16GB — small enough to stay loaded 24/7 next to everything else. Going bigger costs headroom and latency for a 45-token classification; going much smaller costs judgement on the one decision you wanted judged. Set OLLAMA_KEEP_ALIVE so the model does not fall out of memory between requests and pay a cold load on the next verdict.

Two judge scopes, and which one to configure

“Which model judges credential access?” has two answers, at two scopes, set in two different places. That split is deliberate, and it starts with where the API key is allowed to live.
There are two provider vocabularies on this page and they are not the same list.openai_compat is a governance wire: a tenant-supplied endpoint dialled through the SSRF fence. It is not a registry row, so passing it to an aux slot is a registry miss and a 400. Conversely openai means the server’s own OpenAI key and only exists on the aux side. See Multi-provider LLM configuration.
Which one you want:
  • A local model for the whole instance → the instance judge. On a single-workspace instance it is the only card you will ever touch.
  • Anthropic, or any OpenAI-compatible endpoint → the workspace governance model. The credential judge has no instance-wide hosted equivalent, and the absence is enforced rather than merely undocumented: a PUT /api/v1/admin/keeper/config carrying judge_provider: anthropic, openai_compat, or any non-Ollama judge_wire is refused with a 400 that names the workspace setting. (Background checks is a different question and a different answer: those five scheduled evaluators are configured at instance scope and may run on a hosted model, but they read their key from the server environment rather than the vault — and a workspace governance model, when set, overrides which model they use for that workspace.)
Why the instance row is Ollama-only:
  • The key would have nowhere to live. The instance settings row carries no credential reference by design — auth belongs in the vault, and the vault is workspace-scoped. A provider that cannot dial without a key therefore has no way to work at instance scope.
  • Nothing would read the wire. A non-Ollama endpoint needs its request path derived from the stored base URL and wire, which is the endpoint contract’s job (internal/llm/endpoint, #1528). The instance judge is still built as a native Ollama client and does not route through that contract, so accepting openai_compat today would store a value nothing consumes — a judge that keeps POSTing the Ollama path, which is configuration that looks fine while denying every request. That is the exact failure this settings table exists to end.
judge_provider and judge_wire are still reported by config get and on the card, because what the judge speaks is worth knowing even when you cannot change it. The only value either one accepts on write is the ollama already in force, so treat them as read-only until both constraints above are lifted. The two scopes are chained rather than independent: if a workspace’s governance-model credential is deleted or revoked, Keeper degrades to the instance judge as it is configured right now — not the values the process booted with — instead of losing its evaluator. See Governance model for that setting in full.

Changing the judge at runtime (no restart)

Those three values are the inherited layer. An OWNER/ADMIN can override any of them at runtime, and the change applies to the next credential request — no restart, no shell access, no editing env on the box:
config get prints provenance per field, which is the difference between the two states that used to look identical: Same surface over HTTP: GET /api/v1/admin/keeper/config (ADMIN+), PUT and DELETE (OWNER/ADMIN). PUT is a partial update — a field you omit is left alone, and a field you send as "" (or null for enabled) returns to inheriting. Every change is journalled with the acting user. Three things worth knowing before you use it:
  • enabled has three states, not two. on / off are overrides; inherit removes the override so KEEPER_ENABLED decides again. That is why the API takes true, false or null rather than a plain boolean.
  • The endpoint is judge-scoped. It repoints the credential-access judge only. The episodic embedder and the chat summarizer keep using keeper.ollama_url from the server config — moving the embedder would silently invalidate every stored vector, so it is deliberately not part of this.
  • You cannot enable Keeper without a judge. Keeper is fail-closed, so an enabled engine with no endpoint or model would DENY every credential request. That configuration is refused with an error rather than accepted.
Turning Keeper on or off also changes how SECRET credentials reach agents (withheld and requested, versus injected directly). That takes effect for runs started after the change — a container already running keeps the environment it was handed.

The judge profile: how much work per decision

The endpoint and model say what decides. The profile says how much it does to decide. Each capability is a separate toggle, because each one makes the prompt bigger or the decision more expensive — and a small model given more context decides worse, not better. Which way that goes for your model is a measurement, not a rule, so nothing here is wired shut. Three presets set them together, so it is not seven knobs:
Two things to know:
  • inherit is not off. Every toggle has three states for the same reason enabled does. Switching one capability off must not pin the other six to whatever they happened to be that afternoon — otherwise your instance is frozen out of every later default change. profile get shows a third provenance value for exactly this, alongside instance, env and default:
  • A decision is only comparable to another taken under the same profile. So the profile is identified by a stamp — the preset name plus every toggle in force — rather than by its name: a standard with precedent switched off by hand is a different judge from standard. profile get prints the stamp, every change to it is journalled with the acting user, and it is what keeper_requests.judge_profile carries on the decision record.
Same surface over HTTP: the judge_profile block of GET/PUT /api/v1/admin/keeper/config. PUT is a partial update, the three boolean toggles take true / false / null (null = follow the profile), and the numbers clear to the profile at 0. Defaults are provisional on purpose. They are a starting point to be replaced by measurement against your own decision history, not a recommendation — it is entirely possible that on your model precedent hurts, which is why it ships off and why it is a toggle rather than a constant.

The one-minute setup

  1. Point it at Ollama. Enter the endpoint in Admin → Keeper → Credential access judge. The models that endpoint serves appear under the model field as you type — click one instead of typing it.
  2. Press Test. Three stages, each naming its own fix:
  3. Turn the engine on and Save. All three commit in one write, because Keeper is fail-closed and enabling it without a working judge would deny every credential request.
The same three stages from the CLI:
judge test and judge models accept --endpoint / --model so you can find a working combination before saving it. judge test exits non-zero when any stage fails, so it works in a script. Stage 3 is the one a ping cannot give you. A 0.5B model passes stages 1 and 2 and still cannot produce a verdict — in production that model denies every credential request, and the reason looks like a security decision rather than a configuration error.

Where to run Ollama, and which address to use

The judge dials from the Crewship daemon, not from inside a crew container. That single fact decides the address: By default Ollama listens on 127.0.0.1:11434, which means only the machine it runs on can reach it. To let Crewship on another host reach it, bind it to that machine’s LAN address:
Ollama has no authentication. Anything that can reach port 11434 can run prompts and list or delete models. Bind it to one specific address, never 0.0.0.0 — or front it with a reverse proxy that adds auth and point Crewship at that instead. host.docker.internal is also the wrong answer here: it resolves inside containers, and the judge dials from the host.
Private and loopback addresses are reachable for the judge regardless of CREWSHIP_ALLOW_PRIVATE_ENDPOINTS — running a model server on your own LAN is the point. Cloud metadata and link-local addresses (169.254.0.0/16 and its IPv6 forms), multicast and the unspecified address stay blocked.

What it costs

The local Ollama judge costs nothing per decision — it is your hardware, and no paid API is called. Turning the Keeper engine on does not start a meter. What is billed per token are the auxiliary evaluators: the behavioural watchdog and the four Keeper Reviews sweeps, which run on the instance’s auxiliary.* models (Anthropic by default). Those are separately opt-in per workspace, and the engine being on does not enable them. Which model each of those runs on is settable at runtime — see Evaluator models below, including the one-press way to move all of them onto the local judge and stop paying per token for governance. The engine ships off. Nothing turns it on for you: keeper_runtime_settings starts empty, and an empty row inherits KEEPER_ENABLED, which defaults to false. (Setting KEEPER_OLLAMA_URL in the environment does auto-enable it at boot unless KEEPER_ENABLED=false is also set — that is the one path that switches it on without anybody clicking.)

Pointing it at an Ollama anywhere on your network

The judge is dialled by the Crewship server, not by your browser. That one sentence resolves the question this field is asked most often — “how can it be localhost when the server runs on a VM and my Ollama runs on my laptop?” It cannot, and it never did: localhost:11434 means Ollama on the server. The card offers the addresses it can work out for you as one-click fills:
  • localhost:11434 — Ollama on the Crewship server itself
  • <your address>:11434the machine you are browsing from, which the daemon can see from the connection and you cannot look up
For a laptop or any other LAN box, two things have to be true:
Bind to the specific LAN address, not 0.0.0.0. Ollama has no authentication: anything that can reach the port can use the models and read the prompts, and a credential-access judge’s prompts contain the agent’s stated intent and conversation context.
Then paste the address and press Connect. That does one GET /api/tags through the SSRF fence and hands back the models that server actually has, as a live inventory — no typing tags from memory, and a single available model is selected for you. The same inventory feeds every other model picker in the app (GET /api/v1/models?provider=OLLAMA), and it follows the judge endpoint rather than the URL the process booted with, so repointing the judge repoints discovery with it. Private and loopback addresses are reachable regardless of CREWSHIP_ALLOW_PRIVATE_ENDPOINTS — a model server on your own LAN is the point. Cloud metadata, link-local, multicast and the unspecified address stay blocked.

The time budget (and why the judge test checks it)

Keeper is fail-closed, so a judge that answers too slowly denies every credential request. The budget used to be a hardcoded 5 seconds, which is fine for a 3B classifier and wrong for anything bigger: a 7B judge on ordinary hardware takes ~12s, so a correctly configured instance denied everything — while crewship keeper judge test, measuring with its own generous timeout, showed three green ticks and said “the judge works”. Both halves are fixed. The budget is a setting:
and the judge check has a fourth stage that compares what it just measured with the budget the credential path will actually allow:
Stage 4 counts toward the overall result, because a judge that cannot answer in time does not work however well it reasons. The budget is also part of the judge’s wiring fingerprint, so changing it rebuilds the judge on the next evaluation — no restart.
The measurement is one warm call. The first request after an idle period pays for a cold model load and will be slower, which is why stage 4 says “it fits, but only just” rather than passing silently when the margin is thin.
If a request does hit the budget, the DENY says so and names the fix rather than reporting context deadline exceeded:
Keeper judge did not answer within 20s — deny by default. If the model is simply slow, raise the budget: crewship keeper config set --judge-timeout 40s

Confirming a finding reaches you

Keeper writes to the inbox when it escalates, and on a DENY at or above the workspace’s risk threshold. Whether that actually reaches a human is not something to discover during the incident it was bought for:
or Send test finding on the Findings & routing card. It sends one synthetic finding through the same inbox writer with the same target resolution and the same realtime push, then prints who it resolved to and why — the named security contact, plus everyone with MANAGER or above. A contact who has since left the workspace is reported as unreachable rather than counted. No model is called, so it costs nothing and works with the engine off; the command exits non-zero when the finding reached nobody.

Watchdog Governance (per workspace)

Beyond the credential gatekeeper, Keeper runs a behavioral watchdog — a behavior monitor that samples agent tool calls and flags anti-patterns (tight loops, scope creep, destructive sequences, credential probing). Workspace OWNERs/ADMINs control it in-app from Admin → Keeper, no server restart needed: The Require a second approver switch sits in the same Admin → Keeper → Watchdog governance panel as the settings above and writes through the same PUT /api/v1/admin/keeper/governance endpoint. Enabling it on a workspace with fewer than two eligible approvers (OWNER/ADMIN/MANAGER) is allowed but surfaces a non-blocking warning — the gate would otherwise deadlock. This is the in-app equivalent of crewship keeper second-approver enable.

The switch is a floor, not a master switch

Turning it off does not mean “one approver is enough”. The credential’s security tier forces four-eyes on its own at L4 · critical, whatever the workspace setting says. So: A tier can only tighten this rule, never loosen it. There is no setting that exempts an L4 credential from four-eyes; the way to make one resolvable by a single person is to give it a lower tier, which is a decision about blast radius, not about approvals. The rule compares the approver against the recorded owner of the agent that raised the escalation (agents.created_by_user_id). An agent with no recorded owner (a legacy row) has no identity to compare against, so the rule cannot be enforced for its escalations and resolution proceeds as before. Every surface that offers you a resolve tells you which of the two applies, so you find out before you are refused rather than from the 403:
  • Admin → Keeper, under the switch, while it is off: names the tier that still requires a second approver.
  • The escalation row in the crew’s escalations panel: says whether the workspace setting, the tier, or both apply to that request.
  • The inbox reading pane, above Approve / Reject: the same notice, in the same words — it is the same component, so the two cannot end up describing one rule differently.
  • crewship inbox get <id>, on the 2nd approver required line.
  • crewship keeper status, on the In force: line under 2nd approver:.
All of them read the answer the server computes when the row is read, not when the escalation was raised — both inputs move afterwards (an admin flips the toggle; a credential is re-tiered), and an answer frozen at raise time would go stale in the direction that matters: a one-click Approve still offered for a credential somebody has since marked critical.
Four-eyes needs a second person who can resolve, and resolving is OWNER/ADMIN. A workspace whose only admin also created the requesting agent has nobody left to approve an L4 request — the rule is doing its job, but there is no one for it to hand the decision to. Promote a second person to ADMIN before you need one: crewship workspace member role <member-id> ADMIN.A refusal is not a permissions problem, and the message says so. If you see “requires a second approver: this escalation was raised by an agent you own”, changing your own role will not help — somebody else has to rule on it.
The same answer is on the API: GET /api/v1/admin/keeper/governance returns an effective_second_approver block (min_security_level, min_security_level_label, source = workspace | tier | none, plus tier_floor_security_level / tier_floor_label — what the tier forces on its own, reported whatever the toggle says). GET /api/v1/crews/{crewId}/escalations carries the per-row answer as second_approver_required with second_approver_by_workspace / second_approver_by_tier and security_level_label. When a security contact is set the escalation targets that person and keeps the MANAGER fanout, so managers still see it as a fallback. Keeper findings push an inbox.updated event in realtime, so the bell badge updates without a manual refresh.
The behavioral watchdog is opt-in and default OFF per workspace — it only runs once an OWNER/ADMIN enables it. The workspace toggle governs the behavioral layer only: the credential-access gatekeeper enforcement stays server-configured (KEEPER_ENABLED), so an in-app toggle can never weaken credential isolation.

API + CLI

Endpoints: GET/PUT /api/v1/admin/keeper/governance (read ADMIN+, write OWNER/ADMIN). PUT is a partial update — send only the field you are changing (enabled, security_contact_user_id, deny_notify_min_risk, watch_spec, watch_presets, require_second_approver, auto_lease_seconds, gov_model_*) and the rest of the row is left untouched, so concurrent single-field edits commute. Every governance change is journaled with the acting user. auto_lease_seconds must be 0 (off) or in [60, 2592000]; values outside that range are rejected with 400 rather than silently clamped, because a rewritten TTL would hide the operator’s misunderstanding of the control.
second-approver is a distinct concern from the behavioral watchdog above — it gates who may resolve a CREDENTIAL escalation, not tool-call monitoring — but it rides on this same governance row and endpoint. See Credentials → Segregation of duties for the full behavior, including the strict no-OWNER-bypass rule.

Watch rules

The watchdog ships with a built-in anti-pattern list (tight loops, scope creep, destructive sequences, credential probing). On top of that, OWNER/ADMINs can author a watch spec — the operator’s own policy — which is injected into the behavior and credential-access evaluator prompts as an authoritative instruction. It has two parts: presets (curated, toggleable rules) and free-form natural-language rules you write yourself. Both are additive — an empty watch spec falls back to the built-in list. Presets: Free-form rules are plain language, e.g. “flag any read of ~/.ssh or id_rsa; flag credential access outside 08:00–18:00”.
In the dashboard the same controls live in Admin → Keeper → Watchdog governance: a checklist of presets and a text area for the free-form rules.
The watch spec is admin-authored config, gated by OWNER/ADMIN and journal-audited, so it is injected as an authoritative policy block, not as untrusted data — that is what lets it instruct the evaluator. Because it is authoritative, write the rules yourself; do not paste rule text from an untrusted source — text that embeds its own instructions would be obeyed. Authoring a watch spec does not enable the watchdog, and while the watchdog is disabled the spec is inert (nothing is injected); run crewship keeper enable (or the dashboard toggle) to activate it. The free-form spec is length-capped server-side.

Governance model (fully-local, no API key)

The model that backs Keeper’s governance is a per-workspace, vault-backed setting, chosen in-app rather than only via server env. This is what makes a no-API-key Keeper possible: point governance at a local Ollama classifier and Keeper runs entirely on your own hardware. It is also the only scope at which the credential-access judge can be hosted — the instance judge speaks native Ollama only, because the endpoint or API key a hosted judge needs comes from the vault and the vault is per workspace. See Two judge scopes for the split and when to use which.
Scope: this setting drives both the credential-access gatekeeper and the auxiliary evaluators (behavior watchdog, skill review, memory health, negative learning) — all of them resolve the same per-workspace governance model at request time. When a slot’s configured provider can’t be built at boot (e.g. the default anthropic slot with no ANTHROPIC_API_KEY), that evaluator falls back to the server’s local Ollama judge instead of going dark — so the full watchdog runs fully-local with no API key anywhere.
Pick a model that answers, not one that thinks. Keeper’s parser is fail-closed: a response it cannot read is a DENY. A reasoning model (qwen3, deepseek-r1, gpt-oss, anything whose ollama show capabilities include thinking) returns its chain of thought separately from its answer, and if the token budget runs out mid-thought the answer is empty — so every credential request is denied, with an HTTP 200 and nothing in the logs. Prefer a small non-reasoning instruct model (qwen2.5:7b, mistral:7b, granite3.3:8b are all fine judges). Keeper’s evaluators are classifiers, not coders.
The endpoint URL may be stored in any shape — http://host:11434, .../v1, .../v1/chat/completions, .../api/chat. They are all normalized to the same mount root and each provider appends the path for the protocol it speaks. One caveat that normalization cannot fix: the judge dials from the daemon, so host.docker.internal (correct for an agent inside a crew container) does not resolve for it — use localhost or the host’s LAN address.
Once set, the governance model is resolved at request time for each credential-access decision: the access gatekeeper builds the configured provider (the openai_compat / remote-ollama endpoint is dialled through the SSRF fence, so a workspace can’t point it at a private/link-local address) and uses it in place of the server default. crewship keeper status shows the resolved provider and model, and flags a degrade:
Revoke-safety. If the credential backing the governance model is deleted or revoked, Keeper does not break — it degrades to the default local Ollama judge and surfaces a warning, rather than leaving the access path with no evaluator (which, being fail-closed, would deny every credential request). A revoke is a soft delete, so the degrade is enforced at resolve time (the lookup treats a soft-deleted credential as unavailable); the fallback shows up as gov_model_degraded in keeper status and a WARN in the audit journal. A working evaluator always exists. Choosing a model does not enable the watchdog; run crewship keeper enable. OWNER/ADMIN only.

Evaluator models

The credential-access judge is one model. The behavioural watchdog and the four Keeper Reviews sweeps are five more — the auxiliary.* slots — and unlike the judge they call a hosted model by default, so they bill per token. Those five used to be settable only through CREWSHIP_AUX_* at boot, which made the one Keeper spend decision an operator could see on the admin page the one they could not make. They are settable at runtime now, per slot, from Admin → Keeper → Judge models or the CLI. OWNER/ADMIN only. Every slot resolves its provider from the instance configuration at the moment it is used, so a change is in force on the next evaluation — no restart, for any of them. That includes the two consumers of curator: the memory consolidator resolves the slot per consolidation, and falls back to the boot-time KEEPER_OLLAMA_URL + KEEPER_MODEL client only when the slot has nothing buildable behind it. The client behind a slot is still built once per distinct provider/model, so leaving a slot alone costs nothing extra.
run_summary and fallback used to be the exception: their provider was built once at boot and copied into every routine executor, so an override there took effect only after a restart, and the card and aux list marked those two rows. Both apply live now, and the marking is gone with the limitation.

How long an evaluator may take

Each slot carries a per-call budget — the --timeout above, and the seconds figure on each row of the Judge models card. It bounds one model call for that slot, the way --judge-timeout bounds one credential decision:
The budget resolves the way the model does: the slot’s own setting, else the fallback slot’s, else the built-in 20s. Clearing it inherits again — it never means “no deadline”, because an evaluator that can hang would hold an agent’s tool call open behind it. When a call does run out of budget the verdict widens to ESCALATE rather than passing silently, so a budget set too low shows up as sweeps that keep asking for review.
This is what the card had been showing since the slots became settable, and what nothing enforced: the number was stored, validated and displayed while every evaluator call ran under the built-in bound, so raising a slow evaluator’s budget changed nothing. It is live now. The shipped defaults are 20s — the bound those calls were already running under — so an instance nobody has configured behaves exactly as it did.run_summary was the last row where that was still true: the post-run verdict never read the field, so its number bounded nothing at all while sitting beside four that worked. It is enforced now, and its shipped default moved from 15s to the same 20s for the same reason the others did — the first real deadline on a call must not be a tighter one.One deliberate exception remains, and the card does not claim otherwise: the memory-consolidation prompt behind curator is batch work over hundreds of journal entries, so it stays on the provider’s own client timeout rather than the slot’s evaluator budget. The curator number still bounds skill review.

Which key an evaluator spends

A hosted evaluator bills a key. By default that is the one in the server’s own environment — ANTHROPIC_API_KEY or OPENAI_API_KEY — which is fine until you hold more than one. Several Anthropic keys is the normal case on an orchestration platform: each carries its own subscription limit, and “which model does this sweep run” was answerable while “whose subscription does it charge” was not. Each evaluator row on the Judge models card now has a key picker, and the CLI takes the credential by name:
Empty is the default and is exactly the previous behaviour, so an instance nobody has touched is unchanged.
Only an API_KEY credential can back an evaluator. The endpoint a hosted evaluator dials is the vendor’s, not one you configure, so an ENDPOINT_URL credential has nothing to do here — it is refused with that reason instead of saved into a slot that fails at first use. An ollama slot ignores the field entirely: it dials the instance judge’s endpoint and needs no key.
The evaluator settings are instance-wide while the vault is per-workspace. The two meet in the middle: you can only pin a key your own workspace holds (the write is refused otherwise), and once pinned it is resolved the same way for every workspace’s evaluations rather than working in one and silently degrading in the rest.
If the key is revoked, the slot degrades to the server’s own environment key and logs a warning — it does not keep dialling with the revoked id, and it does not take the evaluator down. A revoke in Crewship is a soft delete, so the reference survives in the row on purpose: re-issuing the credential under the same id puts the slot straight back. crewship keeper aux test <slot> is the fastest way to find out which state you are in.

Running governance for free

or Use local judge for all on the Judge models card. Every slot is pointed at the instance judge’s endpoint and model — the same local model that already decides credential access — so the sweeps and the watchdog stop billing per token. It writes explicit per-slot overrides rather than a mode flag, so each row still shows what it resolves to and aux reset <slot> still returns one slot to the server’s configuration.
This is a cost decision, not a free one. A 7B local classifier writes blunter findings than claude-haiku-4-5, and the sweeps are the surfaces where nuance shows (a skill review that only ever says “looks fine” is worse than no sweep, because it reads as coverage). Try it on behavior first and read a few findings before moving all five.
Providers are anthropic, openai, and ollama — the set this build can actually construct, served from the provider registry rather than written out, so this list and the builder cannot disagree (crewship provider list prints it). Google/Gemini ids appear in model pickers elsewhere in the app, but there is no Gemini provider in the evaluator path, so choosing one here is refused with that reason rather than saved into a slot that fails at first use. ollama means the instance judge’s endpoint (crewship keeper config), not KEEPER_OLLAMA_URL from the environment the server booted in — so moving the judge moves these with it. openai_compat is not valid here; it belongs to the governance-judge vocabulary described earlier on this page.
A slot with no override inherits CREWSHIP_AUX_<SLOT>_{PROVIDER,MODEL,TIMEOUT}, or the shipped default (anthropic / claude-haiku-4-5) when that is unset — retargeted at the first registry provider whose key you actually have, so an instance holding only an OPENAI_API_KEY boots on openai / gpt-5.4-mini instead of six slots demanding a key nobody owns. An instance holding both keys keeps the Anthropic default; declaration order breaks the tie, so adding a second key never silently repoints a working evaluator (details). aux list labels each field set here, from server config, or shipped default, so you can tell whether a reset would change anything. An override that cannot be built — anthropic with neither a pinned key nor an ANTHROPIC_API_KEY — leaves that slot on its previous provider and logs the reason, rather than taking the evaluator down.

Testing an evaluator without waiting for a sweep

The Judge models card reports not probed against each evaluator, because rendering a status page must not call a paid API. That default is right and it used to be a dead end: five configured judges and no way to learn whether any of them worked until a sweep ran and failed. Each row has a test link, and crewship keeper aux test <slot> is the same check from a shell. It runs one real evaluation against that slot’s resolved model and reports the same stages the judge check uses — a verdict, and whether it arrived inside the credential path’s budget — so a local and a hosted evaluator are held to the same bar. It costs one call, on a button you pressed, and it shares the instance-wide probe rate limit with the judge check.

Running a review on demand

test answers “can this model be reached”. It does not answer “what does this check say about my crew right now” — and until recently nothing did, because the four evaluators ran on a timer or not at all. The behaviour watchdog fires only on a tool call, so there was no way to stage one: a security control that had never been exercised outside its unit tests. Each evaluator row now has a run now link beside test, and the same trigger is a CLI command:
With no flags the server picks the subject from your workspace, so “check it now” is one command: Flags pin the subject instead; anything you leave out is still derived:
Backed by POST /api/v1/admin/keeper/review/{slot}/run (OWNER/ADMIN). It calls the same handler the scheduler and the sidecars call, so a manual run is not a second-class event: same policy resolution, same keeper_requests row, same inbox escalation on DENY/ESCALATE, same realtime push. The response carries the request_id, which is how you find the run in crewship keeper requests.
The workspace comes from your session, never from the body — a workspace_id that disagrees is refused rather than quietly rewritten, and a crew_id or agent_id belonging to another workspace is refused outright. The internal endpoints get that binding from a workspace-bound sidecar token; an admin session has no such binding, so the route checks it explicitly.
A run costs one call to that slot’s model — crewship keeper aux list says which. negative-learning is the one with a side effect: on ALLOW it writes a lesson into the named agent’s lessons.md, exactly as the real failure hook would. A workspace with no recorded failure is told so rather than given an invented one to reason about.

The run budget

Because a run spends money, the route is metered — the same way the evaluator probe on the Judge models card is, and by the same code. The cap is instance-wide, not per user or per IP: what is being rationed is the instance’s evaluator spend, not one operator’s share of it. It ships at 60 runs/hour with a burst of four, which reads as: one full pass over the four evaluators goes through immediately, then one run a minute. That is well above running things by hand and well below what a held-down button, a retry loop, or a routine wired to crewship keeper review run would do. Over the cap you get a 429 that says when to come back, plus a Retry-After header for anything that isn’t a person:
Requests that never reach a model — an unknown slot, a crew from another workspace, a workspace with no failure to learn from — do not spend the budget. Tune it like any other limiter:
The burst stays at one pass over the four evaluators whatever you set, so “check everything now” is never the thing that trips the limit.

Audit Trail

Every Keeper decision is audited with:
  • Full prompt text (truncated to 2000 chars for storage)
  • Raw LLM response (truncated to 2000 chars)
  • Decision, reason, and risk score
  • Agent ID, crew ID, credential name
  • Timestamp
These are stored in the GatekeeperResponse.Prompt and GatekeeperResponse.RawLLMResponse fields (not serialized to the agent, only for observability).

Browsing the audit log from the CLI

Backed by GET /api/v1/admin/keeper/requests (ADMIN/OWNER only), scoped to agents in the current workspace — same rows the in-app Keeper reviews panel shows. Each row carries the agent, credential, intent, decision, risk score, and timestamps.

Decisions

Keeper watches its own decisions

A fail-closed security layer that denies everything looks exactly like a working one from the outside — the only person who can tell the difference is the agent being refused, and agents do not file bugs. That is how #1624 shipped a Keeper which denied every credential request and survived several milestones before anyone noticed. So Keeper now keeps a rolling window of its own verdicts, per workspace, and raises an inbox item when the shape of them stops making sense. It watches the output, not any particular input, which is what makes it the one check that also covers failures nobody anticipated. What is tracked (the last 200 decisions per workspace, /keeper/request and /keeper/execute together):
  • the ALLOW / DENY / ESCALATE share
  • how often the judge failed to supply a verdict at all — unreachable, over its time budget, or unparseable — leaving the fail-closed fallback to decide
  • p95 verdict latency
When it alarms: When both hold, only judge_failure_spike is raised: an unusable judge denies everything by construction, so the collapsed allow rate is its symptom and pointing you at the watch policy instead of the model would waste the investigation. It will not page you on a quiet instance. Nothing fires until at least 20 decisions are in the window. Four refusals on a fresh install is not evidence of a malfunction, and an alarm that cries wolf in week one is an alarm that gets muted before the week it matters. Instances with no judge configured at all are not counted either — those deny by configuration, not by fault. The alarm lands as a non-blocking inbox item for OWNER/ADMIN, under the system.health notification category (so it can reach Slack/email if that category is switched on for a channel — see Integrations). There is nothing to approve on it; the card carries the distribution that raised it and points at crewship keeper judge test. Repeats for the same workspace and the same alarm are collapsed to one card per 6 hours, so an outage that persists does not bury the inbox.
The window lives in memory. Restarting the server clears it, which delays an alarm by 20 decisions and can never invent a false one. It is a rolling window by count, not by time, so an instance that was broken and got fixed goes quiet on its own once healthy decisions have pushed the bad ones out.

What’s Next

Container Isolation

UID boundaries, network policies, and the full 5-layer isolation model.

Credentials

Credential types, priority-based selection, and the CredStore.

Encryption

AES-256-GCM encryption details and key versioning.

Orchestration

How Keeper integrates with mission task approval gates.