Hosting Studio for a team¶
Studio's default is one developer's laptop, and the quickstart covers that. This page is the other shape: a shared instance that other people sign in to.
Until 0.2.1 this page did not exist, and neither
KNEO_STUDIO_USERS_FILE nor KNEO_STUDIO_OIDC_PROVIDER_FILE appeared in any
published document — the login modes were 0.2.0's headline feature and could not
be configured from anything a user could read (audit M6). Everything below is
transcribed from the loaders that enforce it.
Running the published image¶
docker run \
-p 8091:8091 \
-v kneo-state:/var/lib/kneo-studio/state \
-v kneo-default:/var/lib/kneo-studio/default \
-v kneo-workspace:/var/lib/kneo-studio/workspace \
-e KNEO_STUDIO_AUTH_MODE=static \
-e KNEO_STUDIO_USERS_FILE=/etc/kneo-studio/users.json \
-e KNEO_STUDIO_DEPLOYMENT_CAP=53687091200 \
-e KNEO_STUDIO_ALLOWED_HOSTS=studio.example.com \
-v kneo-secrets:/etc/kneo-studio:ro \
ghcr.io/kneo-agent/kneo-studio:<version>
It will refuse to start without a login mode and a cap, deliberately. The
image binds 0.0.0.0 so it is reachable at all, and Studio refuses an
unauthenticated routable bind — every request would be the local owner with full
rights over the project tree. It also refuses a shared deployment with no cap of
its own, because the built-in one is a laptop number chosen by one person for
themselves. Both refusals print one line and exit; neither is a bug to work
around.
| Port | 8091, inside the container and by default outside it |
| State | /var/lib/kneo-studio/state — the store, sessions and the analysis keyring |
| Default project | /var/lib/kneo-studio/default — the attached project bootstrap creates |
| Workspace | /var/lib/kneo-studio/workspace — where members' own projects are allocated |
The state path doubles, and that surprises people. XDG_STATE_HOME is
/var/lib/kneo-studio/state, and Studio puts its own directory inside whatever
that names — so the database is at
/var/lib/kneo-studio/state/kneo-studio/studio.db, with kneo-studio appearing
twice. That is the XDG convention working as intended (the variable names a
base, not an application directory), but it means a backup script pointed at
the shorter path copies an empty directory.
The three volumes may not nest. The state store inside a project root would
make a project directory contain the database describing it, and Studio refuses
to start. workspace inside default makes every managed allocation land in the
default project's tree, and creation answers 409 project_root_overlap for every
caller. The image's own paths are already separate; keep them so if you remap.
Bind mounts need chown¶
The container runs as uid 10001. A Docker volume is created with the right owner and needs nothing; a bind-mounted host directory arrives owned by whoever owns it on the host, and Studio then cannot create or secure its state directory.
Studio refuses with a short message naming that command rather than a traceback
— but it is still the commonest reason a first docker run stops.
PostgreSQL in the image¶
The image installs the postgres extra, so KNEO_STUDIO_DB_URL takes a
postgresql+psycopg:// URL directly:
Before 0.7.0 it did not, and the failure was 47 lines of driver traceback with no Studio refusal in it — the one configuration the "more than one replica" advice below requires.
Health¶
GET /api/readyz answers {"status": "ready", "store": true} with 200, or
503 and "degraded" when the store cannot be reached. It is unauthenticated
and exempt from the Host check so a container health check can reach it; it
reports nothing about the deployment beyond those two fields.
It deliberately does not check the platform. A Studio whose kneo-serv is
unreachable is degraded, not unready — taking it out of rotation would remove
the surface that explains the problem, and restarting it fixes nothing.
The image carries a HEALTHCHECK pointed at it already.
Choose a login mode¶
KNEO_STUDIO_AUTH_MODE takes one of three values.
| Mode | Who it is for | What it needs |
|---|---|---|
none (default) |
one person, on their own machine | nothing — but it is refused on a non-loopback bind |
static |
a small team with a file of accounts | a users file |
oidc |
an organisation with an identity provider | a provider file |
none is not a weaker password; it is no authentication — every request is the
local owner. Studio therefore refuses to serve none on a routable address
rather than trusting you meant it. A shared instance also has to
declare its storage cap.
static — a users file¶
export KNEO_STUDIO_AUTH_MODE=static
export KNEO_STUDIO_USERS_FILE=/etc/kneo-studio/users.json
export KNEO_STUDIO_ALLOWED_HOSTS=studio.example.com # the name people type
export KNEO_STUDIO_DEPLOYMENT_CAP=53687091200 # shared: declare it
The users file is owner-only, or Studio refuses to start. It holds password
hashes, and since 0.5.0 it is held to the same rule as the analysis keyring: a
file with any group or other permission bit set — read, write or execute — is
refused at startup, naming the path, the mode and the fix — chmod 600. Owner-only means owned by the account Studio
runs as; in the published image that is uid 10001, so a bind-mounted file
needs chown 10001 as well as the mode (a 0600 file the process does not own
is refused as unreadable instead). The image can do the hand-over itself, which
needs no root on the host:
docker run --rm --user 0 -v kneo-secrets:/etc/kneo-studio \
-v "$PWD/users.json:/tmp/users.json:ro" --entrypoint sh IMAGE -c \
'cp /tmp/users.json /etc/kneo-studio/ && chown 10001:10001 /etc/kneo-studio/users.json && chmod 600 /etc/kneo-studio/users.json'
# then mount the volume read-only: -v kneo-secrets:/etc/kneo-studio:ro
KNEO_STUDIO_ALLOWED_HOSTS is not optional here, and it is the first thing
that goes wrong: a request whose Host is not on the list is refused with
403 host_not_allowed before it reaches the login route, so a mistyped or
missing entry looks like a broken sign-in rather than a configuration error.
Studio's Host allowlist is a DNS-rebinding defence and does not read your proxy's
mind. (Found by running this page's own instructions — the first draft omitted
it, and every login 403'd.)
The file is JSON: either a list of users, or an object with a users list.
{
"users": [
{
"username": "ada",
"password_hash": "$argon2id$v=19$m=65536,t=3,p=4$…",
"display_name": "Ada Lovelace"
}
]
}
usernameandpassword_hashare required;display_namedefaults to the username.password_hashmust be an argon2id hash. A plaintext password is refused, not accepted with a warning — "the failure mode is a file of plaintext passwords in a backup".- A username appearing twice is refused, because which password worked would otherwise depend on file order.
- An empty list is refused:
staticwith no users authenticates nobody.
All of these are checked at startup, not at first login. An instance that cannot authenticate anyone should say so before someone tries to sign in.
What startup checks of the hash is its $argon2id$ prefix, and nothing
more [0.9.0 Phase-5 docs audit]. A hash that starts correctly but is
truncated or mangled — a copy-paste that lost its tail — loads without a word,
and that account then answers 401 invalid_credentials to its correct
password, which looks exactly like a wrong one. If one user cannot sign in and
the others can, re-mint that user's hash before suspecting the password.
Failed sign-ins are throttled per account and per address, and password checks run a bounded number at a time; the numbers, the codes and the settings are in the quickstart's Login attempts.
Producing a hash¶
argon2-cffi is already installed as a dependency, so the interpreter running
Studio can mint one:
python -c 'from argon2 import PasswordHasher; import getpass; \
print(PasswordHasher().hash(getpass.getpass("password: ")))'
getpass rather than an argument, so the password does not land in your shell
history.
Hash every password with the same parameters — mint them all with the same
command, as above. A sign-in for an unknown username is checked against a decoy
hash, so that its timing does not reveal which usernames exist. Since 0.9.0,
that decoy is built with the configured users' argon2
parameters; when the file holds more than one parameter set it takes the
costliest and logs a warning, because login time can then tell an account with a
cheaper hash from an unknown username. 0.8.0 and earlier always build the decoy
with argon2-cffi's defaults, which the command above also uses — one more
reason to mint every hash with it.
oidc — a provider file¶
export KNEO_STUDIO_AUTH_MODE=oidc
export KNEO_STUDIO_OIDC_PROVIDER_FILE=/etc/kneo-studio/provider.json
The provider file holds the client secret, and the rule stated for the users
file above applies unchanged: owner-only (chmod 600, owned by the account
Studio runs as) or Studio refuses to start, since 0.5.0.
{
"provider_id": "corp-idp",
"issuer": "https://login.example.com/tenant-id/v2.0",
"client_id": "…",
"client_secret": "…",
"callback_origin": "https://studio.example.com",
"authorization_endpoint": "https://login.example.com/tenant-id/oauth2/v2.0/authorize",
"token_endpoint": "https://login.example.com/tenant-id/oauth2/v2.0/token",
"jwks_uri": "https://login.example.com/tenant-id/discovery/v2.0/keys",
"redirect_allowlist": [
"https://studio.example.com/api/session/oidc/callback"
],
"admission": {
"mode": "allowlist",
"email_domains": ["example.com"]
},
"bootstrap": {
"owner": {"kind": "oidc", "issuer": "https://login.example.com/tenant-id/v2.0", "subject": "…"}
}
}
Required: provider_id, issuer, client_id, client_secret,
callback_origin, the three endpoints authorization_endpoint,
token_endpoint and jwks_uri, a non-empty redirect_allowlist,
and an admission block.
This example was missing the last four until 2026-08-15, so an operator who copied it got
ProviderConfigErrorand no bound port. The page claimed to have been written by running it; that was true of thestaticpath and not of this one. The block above now loads — checked by feeding it toload_provider(), not by reading the loader.
There is no discovery shortcut. This release does not fetch
.well-known/openid-configuration; naming a discovery key is refused outright
rather than ignored, because a metadata document silently overriding what you
configured is worse than being told to write three URLs. Copy them from your
provider's published metadata.
redirect_allowlist must contain the derived callback URI exactly —
<callback_origin>/api/session/oidc/callback. Registering it with your provider
is necessary and not sufficient: Studio builds its own redirect from
callback_origin, checks that it is in this list, and refuses to start if it is
not. That catches a callback_origin typo at startup instead of at somebody's
first sign-in, where it surfaces as the provider's opaque
redirect_uri_mismatch and sends you to the wrong console.
It is a startup cross-check and nothing else. Studio never accepts a redirect
target from a request, so any entry beyond the derived URI is inert — listing
three does not permit three. Nothing here is what stops an open redirect; the
post-login return path is bounded separately, by
safe_return_to, which admits only same-origin paths.
issueris the exactissyour provider stamps into tokens, and it is required because it cannot be derived. Providers that scope the issuer per tenant (Azure AD among them) publish an issuer that is not the URL you configured discovery with, and a mismatch is refused at verification.callback_originis where the browser lands, and Studio builds the redirect URI as<callback_origin>/api/session/oidc/callback. Register that exact URI with the provider.- Every configured URL is checked for transport before it is used, and a
configured endpoint whose own query would shadow a protocol parameter
(
state,nonce,code_challenge,grant_type, …) is refused at load rather than at the first login. - Signing keys are cached for five minutes and fetched with a 15-second
timeout, in a worker thread, so a slow key endpoint delays the sign-in that
needs it and nobody else. Rotating a key at the provider needs no restart: a
token signed with a new
kidtriggers a fresh read at once; a key you remove from the set keeps verifying for at most those five minutes. - The ID token must be signed with an asymmetric algorithm:
RS256,RS384,RS512,ES256,ES384orES512, and, since 0.9.0,PS256,PS384orPS512.noneand theHS*family are refused, since an HMAC "signed" with the provider's published key would verify. Any other algorithm fails the sign-in asprovider_error, with the reason in Studio's log; on 0.8.0 and earlier that includes a provider signing with RSA-PSS.
admission — who may exist¶
A valid token is not permission to have an account, so there is no default; the block is required.
mode |
Meaning |
|---|---|
allowlist |
only identities matching a rule may become principals; a matching identity becomes an active principal at its first sign-in |
provision |
any identity the provider authenticates becomes a pending principal, which an operator approves with kneo-studio principal approve |
Allowlist rules — hosted_domains, email_domains, groups, subjects — are
lists, and an identity matching any one rule is admitted. Each reads one claim
of the verified ID token [0.9.0 Phase-5 docs audit]:
| Rule | Reads |
|---|---|
hosted_domains |
hd |
email_domains |
the domain of email, only when the token also carries email_verified: true; an unverified or absent flag never matches |
groups |
groups, or roles when there is no groups claim |
subjects |
sub |
hosted_domains, email_domains and groups are matched
case-insensitively. subjects is matched exactly, because an OIDC sub is a
case-sensitive opaque identifier: Ada and ada are different accounts, and
Studio must not fold one into the other.
The allowlist is checked at every sign-in, not only the first. An active principal whose claims no longer match — removed from the group at the IdP, or outside a rule you narrowed — is refused its next login.
Two refusals are worth knowing before you hit them: allowlist with no rules
is refused (it admits nobody, and "empty means allow everything" is the other
defensible reading, which is exactly why you may not have to guess), and
provision with rules is refused rather than ignoring them, because an
operator who wrote both believes they apply.
Signing in is two steps, and the first one fails¶
Authenticating is not the same as being allowed to exist. In static mode,
and in oidc under provision, the first sign-in registers the identity as
pending, and an operator admits it [0.9.0 Phase-5 docs audit — this said
"oidcunderallowlist", which is backwards]. Under static a correct
password gets you:
403 not_admitted
this account is awaiting admission; an operator approves it with
`kneo-studio principal approve`
Under oidc the sign-in is a browser redirect, so there is no 403: the
browser is sent back to the sign-in page with ?error=not_admitted, which reads
"Your account is not admitted to this instance yet." Under oidc allowlist
there is no pending step: an identity that matches a rule is an active principal
at its first sign-in, and one that matches none is sent back with the same
not_admitted and no principal is created (the refusal is audited) — there is
nothing to approve, and the fix is the allowlist.
That is the design, not a fault. Walked through, for static:
$ kneo-studio principal list
active operator host
pending ada kneo-studio:static
active local-owner local
$ kneo-studio principal approve --subject ada --as "your name"
approved ada (kneo-studio:static)
Now the same credentials return 200. --as is a label recorded on the audit
row for the human running the command; it is never treated as identity. --issuer
is needed only when one subject exists under more than one issuer.
(This section exists because running the page's own instructions produced a 403 where the draft promised a login. A hosting guide that has not been executed is a guess.)
Becoming the owner¶
A fresh shared instance has no owner, and approving a principal does not make
one. One way per login mode [0.9.0 Phase-5 docs audit]:
oidc:bootstrap.ownerin the provider file names{kind, issuer, subject}in advance; that login becomes the owner. It is the only way anoidcinstance gains an owner, because the OIDC callback carries no claim token. All three fields are required andkindmust be"oidc"; a partial or mistyped block is refused at startup (ProviderConfigError), not left to match nothing.subjectis taken exactly as written, case and surrounding whitespace included: it must equal thesubyour provider signs, byte for byte. A non-string value is refused rather than converted. Underallowlistthe owner adopts at their first sign-in; underprovisionthat first sign-in is pending, and they adopt at the next one, after you approve them.static:kneo-studio bootstrap-tokenmints a one-time claim. It is printed once; paste it into the sign-in form's I have an adoption token field on the sign-in that should become the owner, before that person signs in for the first time — a sign-in without it registers them as pending, and a pending account cannot adopt. The command refuses underoidc.
Both principal and bootstrap-token are audited host commands: they run on
the machine, never through the API.
Adoption is one-way. It disables the local owner, which is the identity every
request is under auth_mode = none, so an adopted instance restarted under
none has nobody to act as. GET /api/me answers 503 ("no active principal"),
and, since 0.9.0, so do GET and POST /api/projects.
On 0.8.0 and earlier those two routes still act for the disabled local owner:
listing its projects, and creating new ones owned by and charged to it. Keep an
adopted instance on static or oidc.
Set KNEO_STUDIO_PROJECT_ROOT before the first host command
The default project is attached to a directory the first process to open the store picks, and host commands open the store. With the variable unset that directory is whatever shell you happened to run the command from — and whoever adopts the instance becomes the owner of that tree and can read files out of it.
Export it alongside the other settings, in the same shell you run
bootstrap-token from and in the service's own environment:
A shared instance refuses to start without it rather than guessing, and refuses
to move an existing project to a different tree — the people granted access were
granted it against the old one. To work with a second tree, attach it as its own
project with project attach.
Removing somebody¶
Deleting someone's entry from users.json is not enough. Studio reads that file
only when somebody signs in. Removing an entry refuses their next login, and a
session they already hold keeps working until it expires. To end their access now,
disable them as below, which also ends their sessions.
Under oidc the same holds for the identity provider. Removing somebody's
account at the IdP refuses their next login, and so, under allowlist, does removing
them from the group or domain it admits, because the allowlist is checked at every
sign-in. Neither touches a session they
already hold: Studio receives no event from the IdP. principal disable is what
ends a live session.
New in 0.3.0. Disabling a principal ends every session it holds and refuses it a login, in one transaction:
$ kneo-studio principal disable --subject ada --as "your name"
disabled ada; 1 session(s) ended, 0 pending invitation(s) revoked
It also revokes every pending invitation that principal issued, and says how many.
If that person is the only active owner of a project, or the project's storage is charged to them, the command refuses and names what is blocking it — because a project with no active owner has nobody who can add one, and a charge on a disabled principal is a bill nobody can move. Name who inherits and the whole thing lands together:
$ kneo-studio principal disable --subject ada
error [sole_owner]: 'p-ada' is the only active owner of proj-7; name a principal to inherit ownership
$ kneo-studio principal disable --subject ada --transfer-to grace --as "your name"
disabled ada; 1 session(s) ended, 0 pending invitation(s) revoked
ownership of proj-7 -> grace
charge for proj-7 -> grace (48213 bytes)
--transfer-to covers both moves, which is nearly always what you want: whoever
created a project is usually both its owner and the account it is charged to. Use
--owner-to and --quota-to when they should go to different people.
Who can inherit: an active member of that project. This is deliberate — the command overrides who may decide, never who is involved, and handing a project to a stranger gives them something they cannot see. Add them as a member first.
If a write is in progress, the charge will not move and you will see
[quota_transfer_busy]. Wait and retry: the writer's lease expires and the reservation
is reconciled, which is bounded. Forcing the move would land the bytes on whichever
side finished last.
When the owner is already gone¶
Two break-glass commands for the case where there is nobody to ask — no session, no password, no cooperation:
$ kneo-studio project reassign-owner --project proj-7 --to grace --as "your name"
$ kneo-studio project reassign-quota --project proj-7 --to grace --as "your name"
Both are operator-only and audited, like every host command. reassign-owner
promotes without removing anyone: guaranteeing the project has an active owner is one
decision, and removing the old one is another that principal disable handles.
reassign-quota is the only path that moves a charge without the target agreeing
and without disabling anyone — principal disable --quota-to or --transfer-to
also moves one without asking, as part of the disable.
Behind a reverse proxy¶
Four settings [0.9.0 Phase-5 docs audit — this said "three"]. A login mode is
required behind a proxy (from 0.3.0), and KNEO_STUDIO_TRUSTED_PROXIES is what
lets sign-in through a forwarding proxy succeed (from 0.7.0):
export KNEO_STUDIO_ALLOWED_HOSTS=studio.example.com
export KNEO_STUDIO_HOST=127.0.0.1 # proxy talks to loopback
export KNEO_STUDIO_TRUSTED_PROXIES=127.0.0.1 # the address the proxy connects FROM
export KNEO_STUDIO_AUTH_MODE=static # or oidc — see above
KNEO_STUDIO_TRUSTED_PROXIES takes IP addresses and CIDR blocks, not hostnames.
Set it to the address Studio sees your proxy coming from — 127.0.0.1 for a
same-host proxy, the container-network address for a sidecar. Studio believes
X-Forwarded-* headers, or an RFC 7239 Forwarded header, only from a peer on
this list, which is why the setting exists: without it any caller could claim to be
a proxy. If your proxy sends both and they disagree, Studio believes neither.
With it set, the session cookie is marked Secure when your proxy reports
X-Forwarded-Proto: https (or Forwarded: proto=https), and only then:
arriving on a routable interface is not evidence that anything was encrypted, and
0.7.0 stopped treating it as such. Keep Strict-Transport-Security at the proxy —
Studio does not send it.
Forgetting it stops being silent in 0.7.0¶
Until 0.7.0 an authenticated instance behind a proxy with no policy started, served
and issued session cookies without Secure — silently, on a connection that was
HTTPS all the way to the browser. From 0.7.0 that configuration is refused at
sign-in: any request carrying forwarding headers from a peer the policy does not
name answers 403 untrusted_forwarding and says which variable to set.
Signing in is refused; nothing else is. Sessions already established keep working, and every other route keeps exactly the checks it had.
The variable stays optional, and unset, blank and empty are one state — no proxy is trusted:
There is no keyword for "no proxy", deliberately. This field takes IP addresses and CIDR blocks — a hostname is refused, because it resolves to something else at some later moment — and a magic word inside a field of addresses is a word that could one day collide with a host somebody named. If nothing forwards to your instance, leave the variable out; a container needs no new setting to run.
If you set the variable correctly and sign-in is still refused, the peer Studio sees is not your proxy — check the launch command below.
Start Studio with kneo-studio, as the container does. The console script
starts the server so that Studio, not the server, reads the forwarding headers.
Launching the application with a bare uvicorn kneo_studio.app:app command line
turns uvicorn's own header handling back on: it rewrites the peer address from
X-Forwarded-For before Studio sees it, KNEO_STUDIO_TRUSTED_PROXIES then never
matches, the cookie loses Secure, and a caller on the same host can pick its own
login-throttle key with one header. That launch is not supported.
If your proxy setup stopped working when you upgraded¶
auth_mode = none behind a proxy is refused from 0.3.0, and this is the one
upgrade in this release that can stop a working deployment. Two shapes:
- Studio will not start:
KNEO_STUDIO_TRUSTED_PROXIESis set whileKNEO_STUDIO_AUTH_MODEisnone(or unset, which meansnone). - Requests come back
403 unauthenticated_proxy: no policy is configured, but requests are arriving with forwarding headers, so something is proxying them.
Both mean the same thing. Under auth_mode = none every request is the local
owner with no credential required, so a proxy in front of Studio published the
owner's API — including writes into the project tree — to everyone who could reach
the proxy. It was reported as a security defect against 0.2.1 and fixed rather than
warned about, because the guarantee on the security page is
written unconditionally.
The fix is to choose one: set KNEO_STUDIO_AUTH_MODE=static or oidc and keep the
proxy, or take the proxy out of the path and reach Studio directly on loopback. If
you are sure no proxy is involved, something in front of Studio is adding
X-Forwarded-* or Forwarded headers; stop it sending them.
The full rules are the transport trust policy.
When an owner asks you to raise a taint cap¶
Studio bounds how much analysis provenance a project may hold. An owner who runs into one of those bounds sees their usage and headroom on the project's limits screen and then has to ask you, because a cap an owner could raise themselves is not a cap.
kneo-studio quota set-taint-cap --project alpha --project-cap 20000
kneo-studio quota set-taint-cap --project alpha --per-principal 2000 --retained-max 200000
Any one flag alone, or several together. There are three bounds, and they answer different questions:
| flag | bounds | notes |
|---|---|---|
--project-cap |
live associations the project holds | superseded ones excluded, so repairing is free |
--per-principal |
live associations any one member holds | so one editor exhausts their own share, not the project's |
--retained-max |
every association ever recorded | cannot go below the rows already retained, and those are never evicted, so in practice it only rises |
Each is refused below what the project already holds, with that number in the message,
so a refusal tells you what you can set. --per-principal is checked against the
busiest member rather than the total, since a value that is fine on average can
already be violated for one person.
--retained-max has its own flag rather than following --project-cap automatically:
a bound that moved as a side effect of changing a different bound is the kind of
coupling you would discover by surprise. If a project keeps hitting it, that is churn —
a repair loop or a heavily upgraded analyzer — not a project holding too much, and the
refusal code says which.
Every change is audited with the old and new values for all three.
Attaching a directory that already exists¶
Members can create projects, and when they do Studio chooses the path —
beneath KNEO_STUDIO_WORKSPACE_BASE, named after the allocated project id. The
client sends a name and never a path, which is what makes it safe for anyone to
call: there is no input to traverse with, and collisions cannot happen.
Set
KNEO_STUDIO_WORKSPACE_BASE, or that capability does not exist. There is no default — on a laptop the single project is the developer's own attached checkout, so nothing needs one — and an unset value is not a half-configured state but a disabled feature:POST /api/projectsrefuses with409 project_creation_disabled, and a member who tries gets a refusal about server configuration they cannot change.The published image sets it already (and gives it its own volume). This paragraph used to describe the allocation as though the variable were always configured, which is true of the image and of nothing else.
Not the same directory as
KNEO_STUDIO_PROJECT_ROOT. Every managed allocation would then land inside the default project's tree, and creation would answer409 project_root_overlapfor every caller, always.
Pointing Studio at a tree that already exists is a different proposition. An
arbitrary directory may hold credentials, another team's work, or your /etc,
and a project root is a directory Studio then reads, writes, exposes to the
project's members and hashes into an exposure digest. So it is a host command,
never an API route, and it works only against a list you declare:
export KNEO_STUDIO_ATTACHABLE_ROOTS=/srv/teams,/srv/archive
kneo-studio project attach --path /srv/teams/payments --owner ada
- Comma-separated absolute directories. Any subtree of a listed directory may be attached, and a listed directory may be attached itself. A relative entry is refused rather than resolved against whatever directory Studio was started from, and a path containing a comma cannot be expressed.
- Unset means the command is disabled, and says so. There is no default,
for the same reason
KNEO_STUDIO_WORKSPACE_BASEhas none: a guess here would be a guess about which of your files Studio may read and hand to somebody else. - The allowlist is about the filesystem, not the string you typed. A symlink inside a listed directory pointing somewhere outside it is refused.
--ownertakes a subject who already exists — they get the owner role and the project's storage charge. Overlap with any existing project's root is refused in both directions: one tree belongs to one project.
Every attach writes an audit row naming the resolved path, because which tree was attached is the question anyone reviewing it later will have.
Registering a file when nobody can¶
Registering a pre-existing file is an owner action, and it does not happen in the browser: Studio's UI offers no register control on purpose. An owner registers through the API route, and the host command below covers the cases the route cannot — because adding a manifest entry widens what sharing the project exposes, and the flow makes the owner accept the whole aggregate rather than one file at a time.
That leaves the case where there is no owner able to ask — an attached tree whose owner has gone, or one you registered before inviting anybody. The host fallback is deliberately two commands:
kneo-studio draft register --project alpha --path agent.yaml
# prints the root identity, the current exposure digest, what is already
# registered, and exits without changing anything
kneo-studio draft register --project alpha --path agent.yaml \
--exposure-digest 4f3c…
The second run refuses if the digest has moved since you were shown it, which is what stops your registration slipping past somebody else's confirmation. The entry is attributed to the operator, never to a Studio user — a host command has no session, and the manifest is not a table to put a guess in.
What a shared instance still owes you¶
- A declared storage cap —
KNEO_STUDIO_DEPLOYMENT_CAP; Studio refuses to start shared without one (why). - A project-count bound per principal —
KNEO_STUDIO_PROJECT_LIMIT, which defaults to 100 live projects and may be set to any positive whole number, higher or lower; zero, a negative or a non-number refuses to start. Unlike the storage cap this does not refuse to start when unset: a byte cap is a resource one principal can exhaust for everyone, while a project count is per-principal, so an unset limit degrades to a generous default rather than a shared cliff.
Which projects count: those where the principal is the quota principal — the one charged for the bytes — not the ones they created and not the ones they are a member of. A project has no singular owner, so counting by membership would multiply-count a project with two owners. A charge moves by accepted transfer or by operator reassignment, and the count follows it.
What is refused at the bound: creating a project, accepting a transfer into
a full principal, an operator reassignment or a bulk principal disable that
would move an estate into one, and project attach. The refusal is
409 project_limit_reached and it names both the count and the limit, because
the remedy depends on which of the two is wrong.
What is never refused: an estate that is already over the limit. A
bootstrapped first project and an adopted estate are grandfathered — an
inherited over-limit principal starts and signs in normally, and only its next
acquisition is refused. Raising the limit is always safe; lowering it below
what principals already hold does not retroactively break them.
- A state store outside every project root, which Studio also enforces.
- Backups of three things, not two. The project directories, the database,
and $XDG_STATE_HOME/kneo-studio/analysis-keyring.json.
An artifact's governance history lives in the database, so project directories
alone are not a backup. But the analysis payloads in that database are sealed
with AES-256-GCM, and the key is not in it — load_keyring(state_home())
resolves the keyring from the state home independently of where the database
lives. On the default SQLite deployment they sit side by side and copying the
directory gets both. Under the PostgreSQL option below they are in different
places by construction, and an operator who backs up Postgres plus the
project directories has everything except the key.
Restoring without it is not a degraded instance, it is a stopped one: the keyring is validated against the live rows before serving, so Studio refuses to start and the sealed payloads are unrecoverable. This bullet named two things until 2026-08-15.
What refuses to start, and why¶
One rule, stated once: when configuration is missing, contradictory or
unreadable, Studio refuses to start rather than guess. Every such refusal is a
StartupRefusal; kneo-studio serve prints its message as one line — what is
wrong, and what to set — and exits non-zero, with no traceback. Nothing below is
a degraded mode. The causes, by the setting that fixes them:
| Refusal | Setting |
|---|---|
A shared instance with no project root, while no default project has been attached yet; or one asked to move an existing default project to a different tree. Shared means any of: a login mode other than none, a non-loopback KNEO_STUDIO_HOST, KNEO_STUDIO_WORKSPACE_BASE set, or a principal other than the local owner in the store |
KNEO_STUDIO_PROJECT_ROOT |
| A shared instance (as above) with no storage cap, or a cap that is not a whole number greater than zero | KNEO_STUDIO_DEPLOYMENT_CAP |
| A project limit that is not a whole number greater than zero | KNEO_STUDIO_PROJECT_LIMIT |
A login mode other than none, static or oidc |
KNEO_STUDIO_AUTH_MODE |
Login mode none on a non-loopback bind |
KNEO_STUDIO_HOST, KNEO_STUDIO_AUTH_MODE |
| A port that is not a number | KNEO_STUDIO_PORT |
KNEO_STUDIO_TRUSTED_PROXIES set while the login mode is none |
KNEO_STUDIO_AUTH_MODE |
A KNEO_STUDIO_TRUSTED_PROXIES entry that is not an IP address or CIDR block, such as a hostname |
KNEO_STUDIO_TRUSTED_PROXIES |
A state directory Studio cannot create or secure — the bind-mount case in Bind mounts need chown, whose message runs to a few lines because it carries the command |
XDG_STATE_HOME, or the directory's owner |
| A state store inside the project root | XDG_STATE_HOME, KNEO_STUDIO_PROJECT_ROOT |
A static users file or oidc provider file that is missing, unreadable, malformed, or has any group or other permission bit set (chmod 600, owned by the account Studio runs as — uid 10001 in the image) |
KNEO_STUDIO_USERS_FILE, KNEO_STUDIO_OIDC_PROVIDER_FILE |
| A recovery interval or lease that is zero, negative or unreadable | KNEO_STUDIO_RECOVERY_INTERVAL_SECONDS, KNEO_STUDIO_RECOVERY_LEASE_SECONDS |
A key id that cannot be read back, no active key id whenever KNEO_STUDIO_ANALYSIS_KEYS is set (even with a single key), an active key id that names none of the configured keys, or a keyring that is world- or group-readable or does not open the live rows |
KNEO_STUDIO_ANALYSIS_KEYS, KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID |
| A store whose schema the migrations cannot read, or an analysis-key rotation left in a state the code cannot continue from | none — the message names the recovery command |
The sections that describe each setting mention its refusal where it matters to the reader; this table is the definition, and the rule does not vary by row. (Until 0.5.0 four of these were described one at a time, each as if it were its own kind of failure — the 0.4.0 audit's deferral "one statement would cover them", pulled forward.)
Test-run recovery¶
A test run that Studio dispatched but never heard back about — the process died
mid-call, or the response was lost — is settled by a background task the server owns:
one pass at startup, then one every KNEO_STUDIO_RECOVERY_INTERVAL_SECONDS
(default 60). It asks the platform whether a run with the dispatch's session
exists and adopts exactly one match; every other answer settles the run as unknown.
It never re-issues a run — a duplicate LLM run is worse than an unresolved
status line.
KNEO_STUDIO_RECOVERY_LEASE_SECONDS (default 300) is how long one worker's
claim on a run lasts. Two Studio processes over one PostgreSQL store will both see a
stale run; the lease is what stops them both settling it, and a worker that dies
mid-recovery parks its run for this long before another may take it over. Set it
longer than a correlation should ever take and shorter than you would wait for a
run's status.
Both must be positive numbers of seconds. A zero, negative or unreadable value
refuses to start rather than silently falling back to the default — a lease of 0
is a fence any second worker walks straight through, and an operator who typed it
meant something.
PostgreSQL¶
KNEO_STUDIO_DB_URL points the state store at PostgreSQL instead of the default
SQLite file — the escape hatch
ADR-013 exists for, and the
reason a shared instance can outgrow a single file. Install the extra and set the
URL:
pip install 'kneo-studio[postgres]'
export KNEO_STUDIO_DB_URL=postgresql+psycopg://user:pass@host/kneo_studio
The schema is created and migrated by Studio itself on first start, the same migrations the SQLite default runs. Remember the keyring: see the bullet above — this is exactly the deployment where the database and the key stop being in the same place.
More than one replica¶
Read this first. Single-instance shared hosting is the supported baseline — one Studio process, any number of people signing in to it. Running several replicas needs everything below and one condition this page did not state until 0.8.0: every replica's project mounts must report the same device and inode, not merely the same contents.
A confirmed exposure is pinned to the project root's device number, inode and canonical path. A replica whose mount disagrees — which is the ordinary case for two hosts mounting one NFS export — refuses content reads on every project whose exposure was confirmed elsewhere, saying "this project's root has changed since its exposure was confirmed". Nothing has changed; that replica is simply somewhere else, and the message cannot tell the difference.
So: a shared database and shared storage are necessary and are not sufficient. Several processes on one host see the same identity and are fine. Across hosts, verify it before relying on it —
stat -c '%d %i'on the project root from each replica must agree — and treat a disagreement as unsupported rather than as something to work around. Loosening the check is not the workaround: it is what stops a re-pointed root inheriting someone else's confirmation.
A single instance generates its analysis key on first run and keeps it beside the state store with owner-only permissions. Two replicas cannot each do that — they would write payloads the other cannot read — so the key comes from the environment instead, and both replicas must be given the same one:
export KNEO_STUDIO_ANALYSIS_KEYS='k1:<base64 32 bytes>'
export KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID=k1
KNEO_STUDIO_ANALYSIS_KEYSis a comma-separated list of<key-id>:<base64 material>entries. Setting it replaces the on-disk keyring entirely; the file is not consulted. A key id is 1–64 characters and may not contain.(it delimits the stored envelope's fields),,or:(they delimit this variable's entries) or control characters; anything else —tenant@prod,k/1— is fine. Studio refuses to start on an id it could not read back rather than sealing payloads under it.KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_IDnames the key used for writes, and is required rather than defaulting to "the only one" — a deployment that later added a second key would otherwise silently change which key writes.- Startup refuses, rather than degrading, when the keyring is incomplete: if a stored payload names a key id the keyring does not carry, Studio does not start. That is deliberate — reaching a per-row decision with an incomplete keyring is the one thing that must not happen, since a missing key is a configuration fault and corrupt ciphertext under a complete keyring is a data fault, and they need different answers.
The login bounds are per process, not per deployment. The failed-attempt throttle, the OIDC start limit and the number of password checks run at once (Login attempts) are each held in one process's memory, so N replicas allow N times each limit. Size them per replica, or put a rate limit at the load balancer if you need one bound across the estate.
Upgrading Studio — stop the estate first¶
A schema upgrade requires a quiescent estate. Stop every Studio process before upgrading, and start them again only on the new version.
This is a precondition, not advice. Studio already refuses to start against a store newer than the running version — "state store is at schema version N, but this Studio supports at most M" — so a replica you forget to upgrade fails closed on its next restart. What that refusal cannot do is stop a process already running when the migration lands: it holds an open connection and never re-checks, so it can keep writing under the old release's assumptions.
Two replicas starting the new version at the same moment is safe, though
[0.6.0]. Their first starts serialize on a database lock: one applies each pending
migration and seeds the default rows, the other waits, sees the work already done,
and comes up clean. Before this, a simultaneous first start could crash the loser on
a duplicate-key error and leave you restarting it by hand. You still stop the old
processes first; you no longer have to stagger the new ones.
For most migrations the cost of that is a lost write. For the analysis keyring it is worse and permanent: an old process can create a payload under a key the upgrade is retiring, and retiring the key then makes that payload unreadable. So the requirement is stated here rather than left to inference.
One part of it is now enforced rather than requested [0.4.0]: a schema migration
refuses to run while an analysis-key rotation session is open, naming the session
and telling you to complete or cancel it first. Nothing is applied and the store is
unchanged. That closes the worst case — a migration rebuilding the very table that
holds the rotation's ownership record, so that afterwards nothing could tell a
completed rotation from an interrupted one. It does not replace stopping the
estate: a running old process is still invisible to the store, which is why the
paragraphs above are a precondition and not a suggestion.
If you run a single instance on a laptop, stopping it is what you were going to do anyway. If you run replicas, take them all down together — a rolling upgrade is not supported for schema changes.
What the 0.6.0 upgrade changes in a 0.5.0 store, and why [0.6.0]. One data
migration runs alongside the schema changes: it reclaims the gap detail of every
0.5.0 analysis whose parameters not carried card could quote a credential. That
card listed the model options an import could not carry — temperature, maxTokens
and the rest — and in 0.5.0 it quoted their values verbatim, including an apiKey the
importer had already identified as a secret and refused to carry anywhere else. So a
0.5.0 store can hold a credential in review content, encrypted at rest and readable by
any viewer of the project. After the upgrade, such an analysis keeps its blocking and
manual counts and its acknowledgements, and opening its detail answers gap detail was
reclaimed; re-import to see it again — the repair the product already names for a
reclaimed payload. Re-importing the same export produces a fresh analysis whose card
shows the option's name and <redacted>. Analyses of workflows with no model options,
and every analysis written by 0.6.0, are untouched.
A credential a pre-0.6.0 import may have written into a draft [0.6.0]. Before
0.6.0, if a workflow repeated one of its own credentials in an agent's system prompt,
its model identifier, or a node name, Studio carried that value into the generated
spec — so it was returned by the import API and written into a plaintext draft under
the project root, beside a gap card stating the value had not been carried. 0.6.0 does
not carry it, cards it as a blocking gap, and refuses the import outright if any other
position still would.
No migration can undo the ones already written, and none is attempted: a generated draft is an artifact's governed base, and rewriting it would move bytes an artifact's revision pins. What to do, in this order:
- Search your project roots for the secret, not for the workflow:
grep -rIl '<the credential>' /path/to/projects. Drafts are plaintext YAML. - Treat any hit as a disclosed credential and rotate it. It was written to disk unencrypted, so it is in your filesystem backups and in anything that read them; deleting the file does not undo that.
- Delete the affected draft and re-import the workflow. 0.6.0's analyzer produces the blocking card, so the re-import tells you which position to fix in the source.
- Artifacts built on such a draft keep pointing at it. Retract or recreate them after the re-import, or the artifact's own snapshot still carries the value.
One thing the upgrade cannot repair for you [0.6.0]. Before 0.6.0, an agent that
carried both a system message and a per-run prompt (promptType: define with text)
was analysed with its system prompt and without the per-run prompt becomes run
input card, so such an artifact could be handed off with no acknowledgement and no
word about the request it dropped. No migration can find those analyses — the defect
is a card that is absent, inside an encrypted payload — so an artifact created before
the upgrade keeps the analysis it has. Re-import the workflow: 0.6.0's analyzer
produces the card, hand-off then waits for the acknowledgement, and the exported spec
carries the system prompt as before.
Disable supervisors and autoscalers for the whole window, not just until the last
old process exits. The refusal above can only fire once the schema version has
actually advanced, so anything that restarts a process during the migration can
bring an old binary back against a store that has not finished moving. systemd
Restart=, a container orchestrator's replica count, or an autoscaler will all do
exactly that. Turn them off, upgrade, then turn them back on.
Added 2026-08-24 by maintainer decision, after an external design review found that this page documented no upgrade precondition while the design assumed one.
Rotating the analysis key¶
You can, from 0.4.0 — and the route is a sequence of deliberate steps rather than
an environment variable, because a rotation that begins by changing configuration is
one that can be half-applied across a fleet. Configuration still has to follow the
store, though, and when is the whole runbook [corrected 2026-09-05, 0.6.0 S4].
The rule the sequence is built around. Every process is configured with a
preferred write key, KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID, and the store checks it
for intent at every startup and every admission refresh — it never uses it; the
store chooses the write identity. What the check accepts depends on the rotation
phase:
| Store phase | KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID may name |
|---|---|
| stable (no session) | only the active id |
| prepared, or rewriting | the source or the target |
| any phase | never a retired id |
A process whose configured id is outside that set refuses: it exits non-zero at
startup, and a running one fails its next analysis access with 503. So the moment
to move the configured id is while the session is open — prepared or rewriting —
when both answers are accepted and each replica can be rolled one at a time. Move it
after complete and the store is stable on the new id while every replica still
names the old, retired one: the whole estate refuses at once.
$ kneo-studio keys status # phase, active id, and what live rows still name
$ kneo-studio keys prepare --target new-key
prepared. session <id>
Every process must now hold BOTH keys. Writes stay on the old id until `keys cut-over`.
# 1. put BOTH keys in KNEO_STUDIO_ANALYSIS_KEYS on every replica (restart each)
$ kneo-studio keys cut-over --session <id>
# 2. now roll KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID=new-key across the replicas, one
# at a time — the store is in a phase that accepts either id, so nothing refuses
$ kneo-studio keys rewrite --session <id> # repeat until it reports nothing left to move
$ kneo-studio keys complete --session <id> # LAST: only once every replica names new-key
retired the source. …
# 3. only now remove old-key from KNEO_STUDIO_ANALYSIS_KEYS
The four-step form of this runbook — with no mention of the configured id — was
executed verbatim on a two-replica deployment during 0.6.0's Phase-1 audit: after
complete, every import and every analysis read answered 503 and every restart
exited non-zero, until the operator worked out which variable to change. The same
rotation with the configured id rolled during the rewrite window was measured at zero
interruption, both replicas serving 201 through cut-over and rewrite. Nothing in the
product changed; the runbook had left out the step that decides the outcome.
There is no initialization step: a store upgraded from an earlier release adopts
the key it is already using automatically at startup (keys init exists as
the idempotent operator surface, and a fresh store initializes itself the same
way). The one store startup will not adopt is a mixed-id predecessor — live
rows under more than one key — which is refused unchanged with the way out
named.
Keep the session id. cut-over, complete and cancel all require it, and none
of them infers which rotation you meant from anything else — that inference is what
made an earlier attempt unable to tell "released" from "still held".
Both keys must be in every process's keyring between prepare and complete. That
is what prepare is for: it makes the new key mandatory before anything writes under
it, so a process that has not been given the key fails its next analysis access rather
than discovering the gap halfway through.
complete refuses until every live payload names the new key and retires the old
one in the same transaction — there is no step that proves the old key is unreferenced
and then separately releases it, because that gap is a window in which the proof stops
being true. Quarantined analyses are counted and excluded: they carry no live key
dependency, so a store that has had a corrupt analysis can still retire a key.
Cancelling is safe only when the configured id agrees with what stays active.
Rows already rewritten stay rewritten, nothing is retired, and
keys resume --source old-key picks the rotation back up — that part holds in either
phase. But a cancel ends the session, and a stable store accepts only its active id:
cancel while prepared and the old key stays active, so a replica already rolled to
the new id refuses; cancel while rewriting and the new key stays active, so a
replica still on the old id refuses. Before cancelling, set every replica's
KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID to the id the cancel will leave active — or resume
instead of cancelling. This paragraph said "safe at any point" until 2026-09-05; it was
false in both cancellable phases.
Before 0.4.0 this section said "you cannot, in this release." Changing
KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_IDon a store holding sealed payloads made the service refuse to start. That refusal still exists and is still right — configuration is not how a rotation is performed — but it is no longer the end of the road.
Why configuration alone was never going to be enough. Reads work across a mixed store already — every payload records the key that sealed it — so the problem was never reading. It was that writes would begin under the new key while old rows kept naming the old one, with no way to rewrite them, so the keyring had to keep holding a key you rotated away from because it leaked, indefinitely, with nothing in the product saying so. The startup refusal existed to stop you reaching that state. Rotation is what finally gets you out of it.
Where each key lives is unchanged:
- on a single-instance install the first key is generated on first run and kept in the state home;
- on a hosted deployment it is whatever
KNEO_STUDIO_ANALYSIS_KEYSnames, and during a rotation that variable must name both keys — untilcompletehas committed, never earlier: between a cancel and aresumethe old key is still authorized and still named by live rows, so admission requires it, and removing it refuses the estate with the missing id named.
This capability was built for 0.3.0 and withdrawn from it, after five rounds of external implementation review each found defects in the previous round's fixes and the fifth recommended withdrawal rather than a sixth attempt. Nothing from that attempt was ever released, so no store carries a trace of it. What ships in 0.4.0 was rebuilt against a written state-transition contract — the thing the withdrawn attempt lacked, and the reviewer's actual diagnosis: there was no model, so each round repaired the instance in front of it.
One thing you should know about that contract: it was reviewed extensively but never confirmed by a party with no stake in it, because this project has no such party available. That is recorded as an explicit waiver rather than glossed, and what stands in for the missing read is executable evidence — the acceptance controls the release lane runs.