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:
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:- The request is a credential-access flow (
RequestTypeempty oraccess) — F4.x request types always reach the LLM - The security level is L1
- The intent string has at least 10 non-whitespace characters
- The intent contains at least 5 distinct non-whitespace characters (blocks trivial filler like
"aaaaaaaaaa"or"aaabbbcccddd") - The intent doesn’t look like a prompt injection (
looksLikeIntentInjection— e.g. noignore previous,system:, or a JSON{...}brace pair) - The request is NOT a
/keeper/executerequest
LLM Evaluation
For L2+ credentials (and L1 execute requests), Keeper sends a structured prompt to the local LLM.Full prompt structure
Full prompt structure
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.Response Parsing
The LLM response is parsed for a JSON object. Defensive measures:- Scan for the first
{and last}to extract JSON - Normalize decision to uppercase
- Unknown decisions default to
DENY(fail closed) - Risk scores clamped to
[1, 10] - 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:/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_idfrom 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
ThecontainsDangerousShellChars 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: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:
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
allowordeny. This command is the escalation being answered; there is nothing left to escalate to. - Once. A settled request answers
409. Your ruling is whatkeeper evallater 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.
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:
keeper requests
tells you which regime produced which answer. Ground truth — keeper 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:
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:
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:
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.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
DENYis still aDENY, 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 5removes 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: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:
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 aDENY. 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 testexercises the real call, thinking suppressed, so its stage-3 verdict is what production will get. Testing the same model by hand withollama runwithout--think=falsewill 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.
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/configcarryingjudge_provider: anthropic,openai_compat, or any non-Ollamajudge_wireis 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.)
- 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 acceptingopenai_compattoday 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:
enabledhas three states, not two.on/offare overrides;inheritremoves the override soKEEPER_ENABLEDdecides again. That is why the API takestrue,falseornullrather 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_urlfrom 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.
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:
-
inheritis notoff. Every toggle has three states for the same reasonenableddoes. 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 getshows a third provenance value for exactly this, alongsideinstance,envanddefault: -
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
standardwith precedent switched off by hand is a different judge fromstandard.profile getprints the stamp, every change to it is journalled with the acting user, and it is whatkeeper_requests.judge_profilecarries on the decision record.
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
- 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.
-
Press Test. Three stages, each naming its own fix:
- 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.
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:
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’sauxiliary.* 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 belocalhost 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>:11434— the machine you are browsing from, which the daemon can see from the connection and you cannot look up
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 — whilecrewship 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:
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.
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: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 the2nd approver requiredline.crewship keeper status, on theIn force:line under2nd approver:.
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
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”.
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.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.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 — theauxiliary.* 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:
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:
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.
crewship keeper aux test <slot> is the
fastest way to find out which state you are in.
Running governance for free
aux reset <slot> still returns one slot to
the server’s configuration.
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.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, andcrewship 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:
Flags pin the subject instead; anything you leave out is still derived:
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.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 tocrewship 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:
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
GatekeeperResponse.Prompt and GatekeeperResponse.RawLLMResponse fields (not serialized to the agent, only for observability).
Browsing the audit log from the CLI
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 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.