Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. A provider verifies one credential: an explicit auth_token from the handshake selects the provider named by auth_provider (defaulting to native, the gateway pairing token), and with no token a local Unix socket presents its kernel peer uid to the peercred provider. A selected provider’s rejection is final: a credential is never retried against another provider.

  2. 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.

  3. Every RPC method except the two handshake methods is classified to a required resource-verb grant and refused without it. initialize carries the credential itself, and cert/renew is 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/new and session/prompt check 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_dir lists 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.access grants 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 (autonomy full, a risk profile with workspace_only = false, the yolo preset, or workspace.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/new and session/prompt repeat 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.

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

ProviderCredentialConfigured by
nativeGateway pairing bearer tokenGateway pairing (/pair); the daemon and gateway share one live token authority
peercredUnix peer uid on the local socketAlways 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 string[] · default []

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 string? · default null

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 integer? · default null

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 string · default ""

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 OidcActorKind · default "service"

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 string[] · default []

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 string · default ""

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 string · default ""

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 string · default ""

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 🔑 secret · default null

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 string[] · default []

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 string · default ""

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 integer · default 86400

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 map · default {}

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 bool · default true

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 bool · default false

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 string[] · default []

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 integer · default 60

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 string[] · default []

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 map · default {}

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 OidcValidation · default "jwks"

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 bool · default false

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 string[] · default []

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 string[] · default []

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 string[] · default []

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 map · default {}

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:

  1. Pair with the gateway as usual to obtain a bearer token.

  2. Give zerocode the token, either in its config:

    [connection.wss]
    uri = "wss://daemon.example.com:9443"
    auth_token = "zc_..."
    

    or via the ZEROCLAW_AUTH_TOKEN environment 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, then auth_token_file, then auth_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 a 0700 config 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’s auth_provider field: 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/subscribe stream is rechecked on every delivery and ends at the first one after its credential expires, its pairing is revoked, or its principal loses Logs:Read.
  • Policy changes: a config save that leaves [oidc], [users], [permission_profiles], and security.trust_daemon_uid unchanged 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_sig reconnect mechanism is continuity only: it preserves the TUI’s registry identity and grants no authority. Every initialize re-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] fieldReplacementNotes
enablednonePresence of an [oidc.<alias>] entry is what enables verification.
instance_url, realm[oidc.<alias>] issuerThe exact issuer URL the IdP advertises in its discovery document; the daemon refuses a discovery document whose issuer differs.
client_id[oidc.<alias>] client_idAlso used to build the interactive_clients / service_clients lists that classify who is signing in.
client_secret[oidc.<alias>] client_secretEncrypted 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_urlnot supportedThere 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>] audienceRequired, 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_mapclaim_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_accessnot equivalentThere 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_secsmax_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 dangling profile_map target or a missing audience is refused with the reason and the previous policy stays in force.
  • If you edit config.toml by hand, run ZEROCLAW_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 the profile_map targets: a profile_map value 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, or gateway.paired_tokens can 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, or tunnel.custom.start_command can 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.