Authentication & principals
Every RPC connection to the daemon binds a principal during the
initialize handshake, and every method call is checked against the grants
the current configuration assigns that principal. This page covers the
provider set, the local-user roster, permission profiles, and, most
importantly, what changes for existing remote connections.
The model in one pass
-
A provider verifies one credential: an explicit
auth_tokenfrom the handshake selects the provider named byauth_provider(defaulting tonative, the gateway pairing token), and with no token a local Unix socket presents its kernel peer uid to thepeercredprovider. A selected provider’s rejection is final: a credential is never retried against another provider. -
The shared resolver maps the verified identity to a canonical principal id and the permission profiles the configuration assigns it. OIDC identities are keyed by validated issuer + subject, local roster identities by their durable
[users.<name>]principal id. -
Every RPC method except the two handshake methods is classified to a required resource-verb grant and refused without it.
initializecarries the credential itself, andcert/renewis authenticated by the mutual-TLS client certificate that presents it, not by a principal. Fine-grained selectors compose on top:- config writes check
config_write_paths; session/newandsession/promptcheck the agent selector and hold the session’s workspace to a directory that agent’s policy lets it both read and write, whether the workspace was named by the request, stored with a resumed session, or restored from a durable one. The session is bound to the resolved directory, not the requested spelling, so retargeting a symlinked cwd after admission does not move it, and a live session held under such an alias is refused. The other session methods do not check the agent yet, as described under What this layer does not do (yet);- running or approving an SOP requires every agent it runs as, with the same tool-selector rule as a session, and creating, saving, or deleting one requires the agents it runs as. A step that names no agent, on the step or on the procedure, counts as the first configured agent alias in sort order, which is the agent the headless executor falls back to. Every step counts, including the steps of a deterministic procedure;
- attachments, personality files, cost queries that name an agent, and cron jobs check the agent selector. An attachment sent by local path must also name an absolute path the destination agent’s policy lets it read. A fleet cost summary lists only the principal’s agents in its per-agent breakdown, but its totals and its per-model usage still cover every agent;
fs/list_dirlists only absolute paths that the policy of an enabled agent the principal may use lets that agent read. It refuses relative paths,..components, and, on Windows, network and device paths.
These roots come from each agent’s resolved policy: its risk profile, the sibling workspaces its
workspace.accessgrants read access to (read and write, for a session workspace), and the shared skills directory, which is read-only. An agent that is not workspace-only (autonomyfull, a risk profile withworkspace_only = false, theyolopreset, orworkspace.unrestricted_filesystem = true) may read any path outside its forbidden paths, so a principal entitled to such an agent may list and open sessions anywhere that agent could.session/newandsession/promptrepeat these checks after waiting for the session’s queue, so a request queued before its principal was narrowed or its credential expired is refused when its turn comes. - config writes check
Authorization is live for edits made through the daemon’s RPC config
methods, which is what zerocode’s config editor uses: editing
[permission_profiles], [users], [oidc], or security.trust_daemon_uid
that way re-compiles the policy at save time. Established native-token and
local connections re-resolve at their next operation, with no reconnect or
restart, and an OIDC connection must initialize again. Edits made
outside the daemon, directly in config.toml, through the web dashboard, or
with zeroclaw config set, apply at the next daemon reload or restart.
Revoking a gateway pairing token through the gateway’s pairing controls
invalidates connections authenticated with it before their next operation.
Removing a token from gateway.paired_tokens by editing config, over RPC
or on disk, has no effect on the running daemon, and connections using the
token stay authorized. The removal applies at the next daemon reload or
restart, unless a pairing change made through the gateway before then
writes the live token set, that token included, back to config. Revoke
tokens through the pairing controls.
Providers
| Provider | Credential | Configured by |
|---|---|---|
native | Gateway pairing bearer token | Gateway pairing (/pair); the daemon and gateway share one live token authority |
peercred | Unix peer uid on the local socket | Always on; [users.<name>].uid maps a uid to a named principal |
oidc.<alias> | JWT or opaque bearer from your IdP | [oidc.<alias>] |
Local connections
On a Unix socket, a connection that presents no token is identified by its
kernel peer uid. While security.trust_daemon_uid is true (the default),
the daemon’s own uid connects as the trusted shared operator with full access, with or
without a [users] roster, so an install with no roster behaves as before
for the account that runs the daemon.
The operator who runs the daemon owns its config file, and local-only
lockout recovery depends on that authority. On a Unix socket, set
security.trust_daemon_uid to false to require every local peer,
including the daemon’s own uid, to map through the roster or present a
token. The setting has no effect on a Windows named pipe, described below.
Any other uid must be mapped by an explicit [users.<name>].uid
entry. An unmapped uid (root included) that presents no token is denied,
whether or not a roster exists; there is no fallback to shared-operator
access. The listener
creates the socket owner-only, so today only the daemon’s own account and
root can reach it; a roster entry decides what any other uid may do once
it can.
Windows named pipes carry no peer uid. With no roster, the pipe ACL is the
credential and a local connection is the shared operator. Once a
[users] roster exists, a local client there must present a token,
security.trust_daemon_uid has no effect, and the daemon’s own account has
no trusted local route. A paired gateway token still authenticates as the
shared operator while the policy compiles, so a client that sends one, over
WSS or over the pipe, can repair the roster live. zerocode’s local pipe
connection sends no token, so without such a client a lockout is repaired
by editing config.toml and restarting.
A client may forward its shell environment in initialize so the
daemon’s subprocesses see its PATH and credential sockets (see
Environment variable pass-through). That
snapshot is kept only for an operator-level principal (the shared
operator, or a roster principal with admin = true) on a local connection.
Every other principal, and every remote connection, gets the daemon’s own
environment instead.
Eligibility is checked again when a session is built or restored. An existing
session that retains a non-empty forwarded environment can be resumed or
prompted only by a currently authorized local operator. Reconnecting over WSS,
or losing admin while a prompt is queued, does not carry that environment into
the next turn: the request is refused before execution. Create a new session to
continue without the forwarded environment. Sessions without forwarded values
remain eligible for normal resume, subject to the other authorization checks.
The retained environment is immutable for the lifetime of its session; refusing
a later request does not rewrite it underneath an already running turn. A
resume also requires the current connection’s authorized environment to match
the session’s retained environment. If it differs, create a new session; an
environment-free session can still be resumed after admin is removed.
Recovery
A remote authentication bypass is never offered: a remote connection always has to present a valid credential. While the authorization policy still compiles, a client holding a paired gateway token or an operator-level principal can repair a lockout from anywhere. Without such a credential, and always in the deny-all state, the route back runs on the host that runs the daemon. Which local route applies depends on whether the policy still compiles.
Locked out of a policy that compiles. Authorization is live and the
local trusted path is intact on a Unix socket; on Windows, see the named
pipe note above. Connect locally as the daemon’s own uid:
with security.trust_daemon_uid = true (the default) that account is the
trusted shared operator whatever the roster says. Repair the offending
entry over that local connection, for example in zerocode’s config editor
or with an RPC config/set of users.alice.uid, and the change is
compiled and published at save time. Editing config.toml or running
zeroclaw config set also repairs it, but only once the daemon reloads or
restarts.
Locked out by a deny-all accepted state. The policy did not compile,
so the accepted state refuses every principal before resolution runs, the
daemon’s own uid and the shared operator included. No RPC repairs it:
config/set and config/reload are refused along with everything else.
Edit config.toml directly as its owner, then restart the daemon so the
repaired sections are compiled and published. The daemon ignores SIGHUP,
so a restart is the step that reloads it.
If security.trust_daemon_uid is set to false, the trusted-uid route is
gone. A policy that compiles can still be repaired live by a client that
presents a paired gateway token, or by a roster principal with admin
grants; otherwise both states repair the same way: edit config.toml as
its owner and restart. Turn the setting off only where that is acceptable.
The users roster
permission_profiles
The [permission_profiles.<alias>] entries granting this user’s permissions, merged by deterministic union. Required; a user with no profile cannot authenticate.
Set it on any surface:
Gateway dashboard
Open /config/users and set the users.<alias>.permission_profiles field.
zerocode
In the Config pane, set the users.<alias>.permission_profiles field.
zeroclaw config
zeroclaw config set users.<alias>.permission_profiles <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_users__<alias>__permission_profiles=
principal_id
Durable principal identifier for this entry; defaults to the entry name. Audit records key on this id, NOT on the entry name, and ownership of sessions, memory, and approvals will key on it once principal-owned storage lands. To rename the entry without orphaning its data, set principal_id to the original id in the same edit. Changing an entry’s effective principal id creates a new principal.
Set it on any surface:
Gateway dashboard
Open /config/users and set the users.<alias>.principal_id field.
zerocode
In the Config pane, set the users.<alias>.principal_id field.
zeroclaw config
zeroclaw config set users.<alias>.principal_id <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_users__<alias>__principal_id=
uid
Unix uid accepted for this user over the local socket (peer credential). Required today: it is the only roster credential the accepted provider set supports.
Set it on any surface:
Gateway dashboard
Open /config/users and set the users.<alias>.uid field.
zerocode
In the Config pane, set the users.<alias>.uid field.
zeroclaw config
zeroclaw config set users.<alias>.uid <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_users__<alias>__uid=
The entry name doubles as the durable principal id unless principal_id
pins one explicitly. Audit records key on that id today. Sessions, memory,
and approvals are not keyed on it yet (see
What this layer does not do (yet)), but
they will be, so to rename an entry without orphaning its data later, set
principal_id to the original id in the same edit.
OIDC
Each [oidc.<alias>] entry is one trust relationship with one issuer;
token verification (offline JWKS or RFC 7662 introspection), claim
mapping, and the lifetime bounds are documented on the section reference:
actor_claim
Dotted path to a claim the issuer stamps on exactly ONE actor kind’s tokens (present, non-null), e.g. Auth0’s gty on client-credentials tokens or Okta’s uid on user tokens. actor_claim_marks names the kind its presence proves; absence proves the other kind. Required for a client that issues both human and machine tokens (declared in neither client list); also checked against declared clients, where contradicting evidence denies. Empty = no claim-based classification, so every accepted client must be declared.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.actor_claim field.
zerocode
In the Config pane, set the oidc.<alias>.actor_claim field.
zeroclaw config
zeroclaw config set oidc.<alias>.actor_claim <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__actor_claim=
actor_claim_marks
Which actor kind the presence of actor_claim proves.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.actor_claim_marks field.
zerocode
In the Config pane, set the oidc.<alias>.actor_claim_marks field.
zeroclaw config
zeroclaw config set oidc.<alias>.actor_claim_marks <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__actor_claim_marks=
allowed_authorized_parties
Allowed azp (authorized party) values, meaning the client the token was issued TO. Empty = no restriction; non-empty = the token must carry an azp claim listed here or authentication fails closed.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.allowed_authorized_parties field.
zerocode
In the Config pane, set the oidc.<alias>.allowed_authorized_parties field.
zeroclaw config
zeroclaw config set oidc.<alias>.allowed_authorized_parties <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__allowed_authorized_parties=
audience
Audience the token must carry in its aud claim for this daemon (typically the client ID or resource identifier registered at the IdP). Required for token verification.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.audience field.
zerocode
In the Config pane, set the oidc.<alias>.audience field.
zeroclaw config
zeroclaw config set oidc.<alias>.audience <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__audience=
claim_path
Dotted path to the verified claim holding this deployment’s role/group values (e.g. realm_access.roles, groups). Must be set explicitly: the daemon refuses to guess where grants live in a token.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.claim_path field.
zerocode
In the Config pane, set the oidc.<alias>.claim_path field.
zeroclaw config
zeroclaw config set oidc.<alias>.claim_path <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__claim_path=
client_id
Client ID this daemon authenticates AS for confidential flows (token introspection; enrollment in a later slice). Defaults to audience when empty.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.client_id field.
zerocode
In the Config pane, set the oidc.<alias>.client_id field.
zeroclaw config
zeroclaw config set oidc.<alias>.client_id <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__client_id=
client_secret 🔑
Client secret for confidential-client flows (token introspection). Not required for JWKS validation. Encrypted at rest.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.client_secret field.
zerocode
In the Config pane, set the oidc.<alias>.client_secret field.
zeroclaw config
zeroclaw config set oidc.<alias>.client_secret # masked input, stored encrypted
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__client_secret=
interactive_clients
Client identities whose tokens are ALWAYS human principals (authorization-code / device-code clients), matched against the verified client_id claim. Their tokens must carry a nonblank sub and resolve through profile_map. A client may appear in only one of service_clients and interactive_clients.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.interactive_clients field.
zerocode
In the Config pane, set the oidc.<alias>.interactive_clients field.
zeroclaw config
zeroclaw config set oidc.<alias>.interactive_clients <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__interactive_clients=
issuer
Issuer URL exactly as it appears in validated token iss claims (e.g. https://sso.example.com/realms/main). Discovery is fetched from <issuer>/.well-known/openid-configuration and its issuer field must match this value exactly.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.issuer field.
zerocode
In the Config pane, set the oidc.<alias>.issuer field.
zeroclaw config
zeroclaw config set oidc.<alias>.issuer <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__issuer=
max_auth_lifetime_secs
Maximum authentication lifetime (seconds) for offline-validated (JWKS) tokens. Offline validation cannot see revocation, so the identity expires at the EARLIER of the token exp and iat + this cap. Must be > 0.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.max_auth_lifetime_secs field.
zerocode
In the Config pane, set the oidc.<alias>.max_auth_lifetime_secs field.
zeroclaw config
zeroclaw config set oidc.<alias>.max_auth_lifetime_secs <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__max_auth_lifetime_secs=
profile_map
Maps a claim value found at claim_path to a [permission_profiles.<alias>] name. Claim values with no mapping grant nothing; an identity mapping to no profile at all is denied.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.profile_map field.
zerocode
In the Config pane, set the oidc.<alias>.profile_map field.
zeroclaw config
zeroclaw config set oidc.<alias>.profile_map <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__profile_map=
require_at_jwt
Require the RFC 9068 typed JWT profile for JWKS validation. Opaque tokens remain valid only through configured introspection.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.require_at_jwt field.
zerocode
In the Config pane, set the oidc.<alias>.require_at_jwt field.
zeroclaw config
zeroclaw config set oidc.<alias>.require_at_jwt <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__require_at_jwt=
require_mfa
Require the token to attest MFA through the IdP aggregate mfa marker or an accepted configured ACR before authentication succeeds.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.require_mfa field.
zerocode
In the Config pane, set the oidc.<alias>.require_mfa field.
zeroclaw config
zeroclaw config set oidc.<alias>.require_mfa <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__require_mfa=
required_acr
Acceptable acr (authentication context class) values. Empty = no requirement; non-empty = the token’s acr claim must be one of these values or authentication fails closed.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.required_acr field.
zerocode
In the Config pane, set the oidc.<alias>.required_acr field.
zeroclaw config
zeroclaw config set oidc.<alias>.required_acr <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__required_acr=
revalidation_secs
Revalidation interval (seconds) for introspection mode: the deadline stamped on each identity after which the next privileged operation must re-introspect or fail closed. 0 = revalidate at every privileged operation.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.revalidation_secs field.
zerocode
In the Config pane, set the oidc.<alias>.revalidation_secs field.
zeroclaw config
zeroclaw config set oidc.<alias>.revalidation_secs <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__revalidation_secs=
service_clients
Client identities whose tokens are ALWAYS service principals (client_credentials callers), matched against the token’s verified client_id claim. Service principals are keyed by issuer + client identity and never inherit human-user assumptions, whatever provider-specific shape their sub takes (<client>@clients, a service-account user id, or the client id itself). Actor classification is declarative, never inferred from the sub/client_id relationship: a token is a service because its client is listed here, a human because its client is listed in interactive_clients, or classified by actor_claim. A token whose client is declared nowhere and carries no configured actor claim evidence is denied. A client may appear in only one of the two lists.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.service_clients field.
zerocode
In the Config pane, set the oidc.<alias>.service_clients field.
zeroclaw config
zeroclaw config set oidc.<alias>.service_clients <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__service_clients=
service_profile_map
Maps a SERVICE client’s verified client_id to a [permission_profiles.<alias>] name. Service principals resolve ONLY through this map, never profile_map, so a machine credential cannot inherit a human profile from similarly-named claims. A service client_id with no entry here is authenticated but entitled to nothing (fail closed).
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.service_profile_map field.
zerocode
In the Config pane, set the oidc.<alias>.service_profile_map field.
zeroclaw config
zeroclaw config set oidc.<alias>.service_profile_map <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__service_profile_map=
validation
How presented tokens are validated.
Set it on any surface:
Gateway dashboard
Open /config/oidc and set the oidc.<alias>.validation field.
zerocode
In the Config pane, set the oidc.<alias>.validation field.
zeroclaw config
zeroclaw config set oidc.<alias>.validation <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_oidc__<alias>__validation=
Enrolling (getting a token to present)
The daemon only verifies tokens; clients obtain them from the IdP. Two browserless flows ship with the CLI:
# Interactive sign-in via the Device Authorization Grant (RFC 8628):
# prints a verification code to enter in any browser, waits for
# approval, then writes the access token to stdout.
export ZEROCLAW_AUTH_TOKEN="$(zeroclaw oidc login corp)"
# Same, via the system browser: Authorization Code + PKCE (S256 only)
# with an RFC 8252 one-shot loopback listener. The mechanisms never
# fall back into each other.
export ZEROCLAW_AUTH_TOKEN="$(zeroclaw oidc login corp --browser)"
# Headless service principals via client_credentials (requires the
# entry's client_secret):
export ZEROCLAW_AUTH_TOKEN="$(zeroclaw oidc token corp)"
Progress messages go to stderr; stdout carries only the token, so both
commands compose with command substitution (the oidc commands run before
any startup prelude that could print, the OTP seed disclosure included).
Nothing is stored: present the token as auth_token in the RPC handshake
(or via the environment variable) before it expires, then re-enroll.
The client trusts the issuer the entry names and nothing else: the
discovery document must assert exactly that issuer before any endpoint it
advertises is used, every endpoint that receives a credential must satisfy
the same URL policy as the issuer (https, or http only for an exact
loopback host), redirects are never followed, response bodies are
size-capped, and a token response is accepted only when it carries a
non-empty Bearer access token. A confidential client (an entry with a
client_secret) authenticates with HTTP Basic on every request; a public
client sends its client_id in the form.
--browser opens the system browser for you on macOS and Linux, and on
every platform it also prints the sign-in URL so you can open it by
hand in a browser on the same machine (the callback lands on a loopback
port of the host running the CLI). The browser opener runs detached
from the CLI’s standard streams, so whatever a launcher writes on its
own stdout cannot contaminate the result: stdout still carries only the
token.
Both callback adapters, the CLI’s loopback listener and the gateway
callback, enforce the RFC 9207 issuer check. A response whose iss
parameter does not match the issuer that started the flow is refused
before its code or its error is acted on. A response that carries no
iss at all is accepted, since the parameter is optional and not every
issuer sends it. When the code exchange returns an id_token next to
the access token, that id_token is validated in full (issuer,
audience, expiry, and the nonce bound to this flow) and then discarded.
Only the access token is ever presented to the daemon. The enrollment
client applies the same suspicion to discovery itself: it refuses a
.well-known/openid-configuration document whose issuer is not an
exact match for the configured issuer, trailing slash included, before
it uses any endpoint named in it (the RFC 8414 check), and it caps the
size of the documents it reads, so a hostile or broken issuer cannot
redirect the flow to endpoints of its choosing or answer with an
unbounded body.
Clients that hold no IdP credentials (the web dashboard, zerocode)
enroll through the gateway instead, which proxies the same flows with
the configured entry’s client credentials: GET /api/oidc/providers
lists aliases, POST /api/oidc/{alias}/device/start and
/device/poll drive the device grant, and GET /oidc/login/{alias}
runs the browser flow, whose one-time callback page hands the token to
the opening window via postMessage (same-origin only) with a manual
copy fallback. The gateway also sends
Cross-Origin-Opener-Policy: same-origin, which severs window.opener
once the popup has navigated through the identity provider, so in
current browsers the manual copy on that page is the handoff that works
today and the postMessage contract is in place for the dashboard
follow-up. These routes are unauthenticated by necessity
(enrollment precedes authentication), rate limited, and grant nothing:
they only relay what the IdP grants after the user approves. Design
rationale and failure-mode table:
docs/security/oidc-browser-pkce-design-8289.md in the repository.
The rate limiting works in layers. Requests that start a flow
(POST /api/oidc/{alias}/device/start, GET /oidc/login/{alias}, and
GET /oidc/callback) count against an enrollment-specific instance of
the gateway’s brute-force limiter: the same thresholds and the same
lockout that govern a bad pairing token, kept on their own ledger, so
an address locked out of enrollment can still pair or present a webhook
signature, and a lockout earned on either of those never blocks
enrollment. A callback counts as an attempt only when it is
unproductive: no live flow state for the state it carries, an issuer
that does not match, an error handed back by the identity provider, no
authorization code, an alias removed while the flow was in flight, or a
code exchange that fails. A callback that completes a sign-in costs
nothing, and neither does one the gateway itself turns away because its
relay capacity is in use, so a crowd of people signing in at once cannot
lock their shared address out. A browser sign-in that is refused because
the pending-flow store is full costs the caller nothing either, for the
same reason. Provider listings, device polls and sign-in starts carry a
per-client budget of 20 requests per minute, which leaves headroom over
RFC 8628’s five-second minimum polling interval (12 polls per minute)
without letting a client spin. A poll counts as an attempt when the
identity provider answers slow_down or rejects the device code
outright, so a client relaying garbage device codes walks into the
existing lockout instead of polling forever; a transport failure on the
gateway’s own leg to the identity provider says nothing about the
caller and is not billed to it. A client over its budget, or locked
out, gets HTTP 429 with a Retry-After naming the delay in seconds,
and zerocode waits at least that delay, and never less than the RFC’s
five-second increment, before its next poll (still clipped to the device
code’s remaining lifetime), and never polls faster than once every five
seconds in any case. At most 16 outbound relays to the identity
provider are in flight at once across all clients, which bounds what the
gateway will do to the IdP on everyone’s behalf. The pending-flow store
holds 32 browser sign-ins at once, and one remote client may hold eight of
them, so a caller that starts sign-ins and never finishes them cannot take
the store away from everyone else for the ten minutes those flows live. Loopback clients are
exempt from the per-client budgets, as they are from every other gateway
auth limit, so a reverse proxy sitting on the same host must enable
trust_forwarded_headers for the per-client limits to apply to the real
callers behind it. Every enrollment response carries
Cache-Control: no-store and Pragma: no-cache on top of the gateway’s
no-referrer policy, because device codes and access tokens travel in
those bodies.
zerocode enrolls over the same API from its own config.
[connection.wss] enroll_url names the gateway’s HTTP origin, and an
optional path prefix is allowed. It must be https:// unless it points
at a loopback address (127.0.0.1, ::1, or localhost), because the
device code and then the access token travel over it, and redirects are
not followed, so a plaintext hop cannot be introduced after the fact.
The enrollment connection uses the same [connection.wss.tls] trust
material as the WSS leg: the configured CA, skip_verify, and the
mutual-TLS client certificate. When that material leaves the certificate
unchecked, zerocode asks before the device code goes out rather than
after the token has arrived, and it asks about the enrollment origin
itself, which may not be the daemon the session connects to afterwards.
Answering always records that origin in skip_verify_routes; a
non-interactive run has nobody to ask, so enrollment stops there. zerocode clips every polling wait to the
device code’s remaining lifetime and never polls after it expires, and
it refuses an advertised lifetime above one hour or a polling interval
above five minutes rather than sleeping on a hostile answer. An
advertised interval of 0 or 1 is floored at RFC 8628’s five-second
default, so a gateway answer cannot put zerocode on a once-a-second
poll the per-client budget would only refuse. The token it receives is
held in memory for that session only.
Permission profiles
admin
Grant everything. When true all other fields are irrelevant.
Set it on any surface:
Gateway dashboard
Open /config/permission_profiles and set the permission_profiles.<alias>.admin field.
zerocode
In the Config pane, set the permission_profiles.<alias>.admin field.
zeroclaw config
zeroclaw config set permission_profiles.<alias>.admin <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_permission_profiles__<alias>__admin=
allowed_agents
Agent aliases holders of this profile may bind or address. Empty grants NO agents; grant every agent with the explicit "*" entry.
Set it on any surface:
Gateway dashboard
Open /config/permission_profiles and set the permission_profiles.<alias>.allowed_agents field.
zerocode
In the Config pane, set the permission_profiles.<alias>.allowed_agents field.
zeroclaw config
zeroclaw config set permission_profiles.<alias>.allowed_agents <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_permission_profiles__<alias>__allowed_agents=
allowed_tools
Tool names holders may cause an agent to run. Empty grants NO tools; broad access requires the explicit "*" entry. This selector composes with the coarse tools = ["execute"] grant, never replaces it: without that grant the holder’s sessions run tool-less whatever is named here. (Note this differs from risk-profile allowed_tools, where empty means unconstrained: permission profiles are deny-by-default. The agent’s own risk-profile policy still applies on top.)
Set it on any surface:
Gateway dashboard
Open /config/permission_profiles and set the permission_profiles.<alias>.allowed_tools field.
zerocode
In the Config pane, set the permission_profiles.<alias>.allowed_tools field.
zeroclaw config
zeroclaw config set permission_profiles.<alias>.allowed_tools <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_permission_profiles__<alias>__allowed_tools=
config_write_paths
Dotted config path prefixes holders may write. A trailing .* grants the subtree (e.g. channels.*); a bare path grants that exact prop; "*" grants every path. Empty grants NO paths.
Set it on any surface:
Gateway dashboard
Open /config/permission_profiles and set the permission_profiles.<alias>.config_write_paths field.
zerocode
In the Config pane, set the permission_profiles.<alias>.config_write_paths field.
zeroclaw config
zeroclaw config set permission_profiles.<alias>.config_write_paths <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_permission_profiles__<alias>__config_write_paths=
grants
Resource-class grants: for each resource kind, the verbs permitted. Resources: system, sessions, memory, cron, config, agents, cost, skills, personality, logs, tui, files, locales, quickstart, channels, providers, models, peer_groups, plugins, tools, sops. Verbs: create, read, update, delete, execute. An unlisted resource is denied.
Set it on any surface:
Gateway dashboard
Open /config/permission_profiles and set the permission_profiles.<alias>.grants field.
zerocode
In the Config pane, set the permission_profiles.<alias>.grants field.
zeroclaw config
zeroclaw config set permission_profiles.<alias>.grants <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_permission_profiles__<alias>__grants=
Profiles are deny-by-default: an unlisted resource is refused, an empty
selector list grants no instances, and broad access requires the explicit
"*" selector or admin = true. Multiple profiles merge by union. For
cron jobs, attachments, personality files, per-agent cost queries, and SOP
authoring, allowed_agents = ["*"] covers only the agents the
configuration defines, not any alias a request names.
Tool selectors compose by intersection at agent assembly, on top of the
coarse grant: model-facing tool execution is tools = ["execute"], and a
principal without that grant receives a tool-less session whatever its
allowed_tools names, including "*". With the grant held, a session
created by a constrained principal only receives the tools its
allowed_tools names (an empty list yields a tool-less session), on top
of whatever the agent’s own risk profile allows. After a queued prompt is
admitted, authorization is rechecked against the shared resolver, including
credential expiry and revocation. The current principal selector narrows
static tools, the deferred search registry, already-activated tools, and
pinned MCP resource content (each pinned block is admitted under its
<server>__<uri> name and is withdrawn from later prompts once the selector
no longer names it); removed tools cannot be reactivated. Reused sessions
are narrowed before their next turn, and rehydrated sessions are rebuilt
under current grants. Agent selectors are checked before a turn and before
rehydration.
Narrowing never adds tools back to an existing agent. After expanding grants, create a new session to receive the expanded surface. No new config snapshot or independent principal-policy cache is stored in the agent.
Deferred MCP instructions are derived from the remaining loadable tools and
shown only when tool_search is exposed. A principal needs both tool_search
and the named deferred MCP tool for on-demand activation; the helper is never
implicitly granted. Permitted mode = "always" MCP tools are preactivated and
remain callable without the helper.
If either the principal’s tool selector or agent selector is constrained,
delegate (bounded and independent), spawn_subagent, and execute_pipeline
are unavailable, including skill aliases wrapping those tools. These nested
paths do not yet carry both current principal ceilings; the ordinary parent
turn remains usable. Admin principals and principals with both selectors set
to "*" keep their agent’s configured nested capabilities.
The existing eight-argument Rust Agent::from_live_config_with_tui_env
constructor remains available. RPC uses the additive
from_live_config_with_tui_env_and_principal_tools constructor and reapplies
the shared resolver’s grants at prompt admission.
That composition does not cover every route to an agent’s tools. Cron jobs and SOP authoring check only the agent selector. A constrained principal holding cron grants can create a shell job for its agent, or give an existing agent job a new prompt and trigger it, and one holding SOP create or update grants can save a procedure whose trigger runs it later. Treat cron and SOP authoring grants as grants of the agent’s tools.
Breaking change: remote WSS requires authentication
From this change on, a remote WSS connection must present auth_token in
initialize. There is no unauthenticated remote fallback.
A [wss] listener enabled with no possible credential path (no
[oidc.<alias>], no paired tokens, and gateway.require_pairing = false)
is rejected by config validation, so no supported surface can save one.
A configuration already on disk in that shape still boots: the daemon
starts and the listener denies every remote handshake. It does not refuse
to load, because an operator has to be able to boot a daemon in order to
repair it.
Invalid [oidc.<alias>], [users], or [permission_profiles] sections
are the separate case. There the authorization policy itself does not
compile, so the daemon installs a deny-all accepted state and logs that
it is doing so until the sections are repaired and reloaded. Every
principal is refused under that state, on remote and local connections
alike, the daemon’s own uid included, so the repair is the on-disk one
described under Recovery above.
Migration for existing remote zerocode users:
-
Pair with the gateway as usual to obtain a bearer token.
-
Give zerocode the token, either in its config:
[connection.wss] uri = "wss://daemon.example.com:9443" auth_token = "zc_..."or via the
ZEROCLAW_AUTH_TOKENenvironment variable, which overrides the config value and keeps the credential out of the file.or by pointing at a file that holds it:
[connection.wss] uri = "wss://daemon.example.com:9443" auth_token_file = "/etc/zeroclaw/zerocode-bearer"Precedence is
ZEROCLAW_AUTH_TOKEN, thenauth_token_file, thenauth_token. On platforms with Unix permission bits, a referenced file that any other account can read is skipped with a warning rather than used, and the next source in that order applies. Elsewhere the file’s ACL is the only guard.The environment variable is the recommended path. When the token is kept in the config file instead, zerocode writes that file owner-only (
0600, in a0700config directory) and repairs the modes of a file that predates this, on platforms with Unix permission bits. On platforms without them the directory ACL is the only guard, so treat the file as a secret there.
An OIDC access token works the same way with auth_provider = "oidc.<alias>".
The gateway HTTP API
The gateway’s configuration and onboarding routes (/api/config*,
/api/quickstart/*, /api/channels/bind) enforce authentication
structurally: one route-layer middleware guards the whole group, so no
individual handler carries (or can forget) a check. The middleware
speaks the same principal model as the RPC path, through the same
provider registry and resolver:
- A paired bearer (
Authorization: Bearer zc_...) resolves to the shared operator with full access, exactly as before. Denials keep the historical 401 shape. - An OIDC bearer presented with the
X-ZeroClaw-Auth-Provider: oidc.<alias>header is verified by that provider and resolved to a scoped principal. Selection is explicit, mirroring the RPC handshake’sauth_providerfield: the named provider’s denial is authoritative, and there is never a fallback between providers. - CORS preflight (
OPTIONS) passes through unauthenticated, as it always has. Any other method outside GET, HEAD, POST, PUT, PATCH and DELETE is refused.
A scoped principal’s Config grants are enforced in two steps. The
route layer applies a coarse floor per HTTP method: a read needs
read, anything else needs at least one of create, update or
delete, so a read-only principal never reaches a mutating handler.
Each mutating handler then authorizes its complete write set before
its first side effect: every config path the mutation will persist,
classified by what it does to the configuration (create for a path it
brings into being, delete for one it removes, update otherwise) and
matched against the profile’s config_write_paths selectors. The
classification follows the operation, not the method: creating a map
key through POST /api/config/map-key, or implicitly through a PUT
under a new alias, needs create; a JSON Patch remove and
DELETE /api/config/map-key need delete; a rename needs delete on
its source and create on its destination; the references a delete or
rename cascade rewrites elsewhere are part of the write set too. One
unauthorized member refuses the whole mutation, batch or cascade, and
nothing is written. Operations whose write set cannot be enumerated up
front (a schema migration of the file, a Quickstart apply) require the
* selector. The persist boundary re-checks the paths about to be
written against what the handler authorized, so a handler cannot
persist more than it authorized.
Policy moves only at that persist boundary: the handler that writes a configuration publishes the authorization state compiled from it as the next accepted revision, and every request is verified and resolved against the accepted snapshot as it stands. Nothing on the request path recompiles policy, so a request that read the configuration before a concurrent persist can never reinstall the older policy over the newer one; a persisted change to a provider’s verification settings, a roster or a profile takes effect on the next request. The daemon’s own RPC surface holds a separate live configuration and reaches the same state through the reload the gateway write flags. Other gateway surfaces keep the pairing check per handler and adopt the layer in follow-ups.
Credential lifecycle
- Expiry ends the connection’s authorization at the deadline; the client re-initializes with a fresh token.
- Introspection revalidation: OIDC introspection identities carry a revalidation deadline; past it, the next operation is refused until the client re-initializes (which re-verifies against the IdP).
- Pairing revocation applies before the connection’s next operation.
- Log subscriptions: an open
logs/subscribestream is rechecked on every delivery and ends at the first one after its credential expires, its pairing is revoked, or its principal losesLogs:Read. - Policy changes: a config save that leaves
[oidc],[users],[permission_profiles], andsecurity.trust_daemon_uidunchanged keeps every binding as it is. A change to any of them publishes a new policy. Native-token and local connections re-resolve against it in place, but the daemon does not keep an OIDC bearer, so the next operation on an OIDC connection is refused until the client re-initializes. - The
tui_id/tui_sigreconnect mechanism is continuity only: it preserves the TUI’s registry identity and grants no authority. Everyinitializere-presents a credential.
Session isolation
Every session a scoped principal creates is stamped with that principal’s id in the live store and on disk (chat backend and ACP store). Scoped principals see and touch only their own sessions: listings are filtered, reads and mutations get one uniform not-found-or-not-owned denial (no existence probing), destructive deletes run as owner-predicated storage statements, and in-flight approvals resolve only for the owner of the session they were raised for. Sessions created before this change (or by unscoped connections) carry no owner: they stay fully visible to unscoped connections and invisible to scoped principals.
Every authenticated principal gets PRIVATE memory: their memory operations
read and write a per-principal plane whose owner travels in every storage
statement, composed with the agent, namespace and tenant dimensions (the
same key under two agents is two rows). The plane follows the principal’s
identity, not the admin bypass: a named administrator’s memory is their
own private plane, so promoting or demoting a user never hides their notes
or redirects their writes. Only the unauthenticated shared operator is on
the shared plane by default; a caller with the admin bypass may name
plane = "shared" on a memory/* request explicitly, which is audited,
and a scoped principal cannot.
The two planes are untouchable from each other in both directions: a shared write can neither name a private row’s storage key nor overwrite a private row, a private write never converts a shared row, ordinary exports and the markdown snapshot carry shared rows only, and the legacy bulk purges reach shared rows only. Private rows are exported and purged through owner-carrying operations.
A session created by a principal has its memory handle pinned to that principal’s private plane for the session’s whole life, whoever prompts it later (an administrator restoring a reaped session restores it on the durable owner’s plane), so the memory tools and per-turn recall inside a scoped session never touch the shared plane. There is no grant that opens the shared plane to a scoped session. Private writes pass the same content scanning and policy gates as shared writes, and private operations are audited with the full scope when memory auditing is enabled. On memory backends without principal support (markdown, lucid, postgres, qdrant today) private memory fails closed with a clear denial rather than silently un-scoping, which for a scoped session means its memory tools refuse.
Migrating from [security.nevis]
The Nevis IAM integration was removed. It was never wired into any
authentication path, so enabling it never authenticated anyone; retiring
it changes no live behavior. Its config table is still accepted so an old
config.toml keeps loading: the daemon discards every value in the table
at load, logs one warning naming the replacement, and removes the table
from config.toml on the next save, whether a full save or the
incremental save the CLI, dashboard and RPC use for a single edit. The
removal touches only that table; comments and other sections keep their
bytes. Nothing is converted automatically: the replacement stack needs
values (an audience, profile grants) the old table never held.
Before you upgrade, copy config.toml somewhere protected. Every save
after the upgrade writes config.toml.bak next to the file for the
duration of the write and removes it once the write is durable, so a
.bak is not a lasting backup. Any copy you keep yourself still carries
the retired table, including a plaintext client_secret if one was
configured, so treat those copies as secret material and revoke the
Nevis client credential at the IdP once you no longer need it.
Field by field:
[security.nevis] field | Replacement | Notes |
|---|---|---|
enabled | none | Presence of an [oidc.<alias>] entry is what enables verification. |
instance_url, realm | [oidc.<alias>] issuer | The exact issuer URL the IdP advertises in its discovery document; the daemon refuses a discovery document whose issuer differs. |
client_id | [oidc.<alias>] client_id | Also used to build the interactive_clients / service_clients lists that classify who is signing in. |
client_secret | [oidc.<alias>] client_secret | Encrypted on disk through the secret store when [secrets] encrypt is on. Required for validation = "introspection" and for client_credentials service sign-ins. Not copied over from the old table: set it again. |
token_validation = "local" | validation = "jwks" | The JWKS URI comes from the issuer’s discovery document. |
token_validation = "remote" | validation = "introspection" | Confidential client required. |
jwks_url | not supported | There is no custom JWKS override: the key-set URI must be advertised as jwks_uri by the configured issuer’s discovery document. That URI may point to a different endpoint; an unadvertised key-set URI cannot be configured. |
| (none) | [oidc.<alias>] audience | Required, and new. The aud the IdP puts in access tokens for this daemon; there was no equivalent to carry over. |
role_mapping[].nevis_role | [oidc.<alias>] claim_path + profile_map | claim_path names the claim that carries the role (e.g. groups); each profile_map key is a claim value, each value a [permission_profiles.<alias>] name. Matching is exact, not case-insensitive as before. |
role_mapping[].zeroclaw_permissions (tool names, or "all") | [permission_profiles.<alias>] allowed_tools + grants.tools = ["execute"] | Profiles are deny-by-default: allowed_tools composes with a coarse tools = ["execute"] grant, and every other resource class (sessions, config, memory, …) must be granted explicitly. "all" is closest to admin = true, which is much broader than “all tools” was; prefer listing resource grants. |
role_mapping[].workspace_access | not equivalent | There is no per-workspace grant. The nearest control is allowed_agents, which scopes a profile to agent aliases. |
require_mfa | [oidc.<alias>] require_mfa and/or required_acr | |
session_timeout_secs | max_auth_lifetime_secs (JWKS) / revalidation_secs (introspection) |
Validate before you restart, so a typo does not lock everyone out (see Recovery under Local connections for the way back if it does). Two routes do that:
- Write the new
[oidc.<alias>]and[permission_profiles]entries through the daemon’s RPC config methods (zerocode’s config editor). The daemon validates the authorization sections before anything is saved or swapped in, so a danglingprofile_maptarget or a missingaudienceis refused with the reason and the previous policy stays in force. - If you edit
config.tomlby hand, runZEROCLAW_AUTH_TOKEN="$(zeroclaw oidc login <alias>)"from the CLI first: it loads the edited file, refuses an[oidc.<alias>]entry that does not validate (issuer, audience, validation mode, client fields), and on success performs a real sign-in against the IdP with the configured client, so the issuer, client id and secret are known good before the daemon restarts on them. This checks the entry, not theprofile_maptargets: aprofile_mapvalue that names no[permission_profiles]entry is caught only by the daemon, which then refuses to compile the policy and starts in the deny-all state described under Recovery, so check those names by eye before the restart, or use the RPC route.
Remote connections that present no credential are refused throughout; neither route opens one.
Rolling back is a config-data operation, not a code revert: reverting
the binary restores the old types, but a table already removed from
config.toml is not restored by anything. Put back the protected copy
you took before the upgrade (or re-add the table by hand). The schema
version does not change for this retirement, so a downgraded binary loads
the restored file without a migration step.
What this layer does not do (yet)
Consolidation and governance derive shared-plane rows and do not run for
private sessions, and administrative access into another principal’s
private memory has no surfaced pathway yet (deny-by-default).
sops/runs and sops/run-detail return the run history of every
procedure to a principal holding Sops:Read, whichever agents it ran as,
unlike cron history. Gateway HTTP routes keep their existing pairing
checks, and channel identities do not resolve into this principal model.
While security.trust_daemon_uid = true (the default) and the policy
compiles, the daemon’s own uid on a Unix socket keeps full access, so a
single-operator install with no [users] roster and valid auth sections
behaves as before.
Permission profiles limit what a principal can do over RPC. They do not
isolate the code a principal causes an agent to run. Session turns, cron
jobs, and SOP steps run as the daemon account. On Unix that code can
connect to the local socket as the daemon’s uid, which is the shared
operator while security.trust_daemon_uid = true (the default) and
otherwise gets whatever roster entry maps that uid. On Windows with no
roster, the pipe makes it the shared operator. It can also edit config.toml, which
the daemon applies at its next reload or restart. Setting
security.trust_daemon_uid = false does not change either. Grant session,
cron, or SOP execution or authoring only to principals you would trust
with operator access. Only OS-level confinement that keeps agent processes
away from the socket and config.toml isolates that code.
Some config paths carry authority themselves, so treat a broad
config_write_paths grant as operator access unless it covers only vetted
leaf settings. For example:
- a principal that can write
permission_profiles,users,oidc,security, orgateway.paired_tokenscan grant itself anything; - one that can write
agents,risk_profiles,cron,channels, or provider settings can change which agents run, with which tools, and where they may read and write; - one that can write
mcp,mcp_bundles, ortunnel.custom.start_commandcan choose programs the daemon account starts.
Config:Update also grants config/reload, which applies whatever
config.toml holds on disk.
zerocode’s remote directory picker opens at the daemon’s filesystem root.
A principal without operator grants may list it only through an enabled
agent whose policy lets it read /, such as one that is not workspace-only.
For any other such principal the picker reports a refusal there until it
opens inside an allowed root instead.