Credentials & capabilities
Part of the security model. Sections keep their original § numbers.
6. Credential blast radius, and the capability broker
The exposure (subscription mode). lever apply sets the credential read from
manager.credential_file (CLAUDE_CODE_OAUTH_TOKEN) as a Hub secret (internal/apply/run.go
runCredential); scion injects it into every agent container’s environment at start (user/owner
scope, single jail = single tenant). Every worker therefore holds the real, long-lived OAuth token
in $CLAUDE_CODE_OAUTH_TOKEN (or a token file in its home). Combined with open internet egress for
the model API (§8), a single prompt-injected worker can read the
token and exfiltrate it, impersonating the operator’s account beyond the jail. The token is ambient,
shared, and long-lived: the highest-value secret in the system. (§4’s
proxy-auth/controller-PAT hardening closes the separate risk of a compromised agent driving the
hub; it does not touch this exposure.) This applies to llm_auth: subscription only (§6.1).
The mechanism. A host-side capability broker holds the raw credential and never projects it
into a container. Agents present their mTLS identity and exchange it for a short-TTL, CN-bound,
typed Ed25519-signed capability token (internal/cap/token) scoped to what their policy allows.
Constraints are fixed at mint; there is no holder-side attenuation. The broker is reachable only via
the allowlisted host alias and is the sole minter, so a worker’s token is strictly weaker than the
manager’s and a delegated token is an online mint, never an offline hand-off. §6.1 covers the
api-key mode and the mixed-instance residual; §6.2 lists what the broker enforces.
6.1 Api-key mode and the mixed-instance residual
An agent in llm_auth: api-key mode (the default) holds only
a short-lived, CN-bound capability(llm) token and routes the model through the broker /llm proxy,
which strips the token and injects the real Console key host-side. Such an agent never receives a
real Anthropic credential. Adding egress: closed (valid only for a uniformly api-key instance)
then seals outbound network to the broker alone (§2.2), closing the §6 blast radius entirely for that
instance.
Mixed instances are unsupported, rejected at config validation. The OAuth credential is set as a
single Hub secret (internal/scion/bringup.go:SecretSet, user/owner scope), gated only by
manager.credential_file and projected into every container regardless of its llm_auth mode.
So in a mixed instance (any subscription agent ⇒ credential_file set ⇒ token hub-projected) an
api-key worker would also hold $CLAUDE_CODE_OAUTH_TOKEN, letting it read the real token and
(egress permitting) reach api.anthropic.com directly, bypassing the proxy’s capability gating; its
key isolation would silently not hold.
Rather than ship that footgun, an instance must be uniformly api-key OR uniformly subscription:
App.Validate (internal/config/validate.go:validateBroker) rejects any config whose effective
agent modes mix the two (mixedLLMAuth), so a mixed instance never reaches apply. The two pure cases
are both clean: all-api-key (the default) = no agent holds a real key, capabilities gated by
signed tokens, and egress: closed is available to seal the network jail-wide; all-subscription
= every agent holds the OAuth key with open egress (the owner/dev trade, by design). This is
not an escalation surface: the api-key flag controls only whether an agent obtains a capability
token, not credential availability, and a worker’s mode is fixed by the config the broker reads
(nothing the manager can rewrite), so it could never conjure a token the host did not project; the
validation gate is about preventing a misleading config, not about containment.
Supporting mixed instances would require per-container egress and/or projecting the OAuth secret only into subscription agents’ projects (not as a hub-wide secret). Neither exists; mixed is a hard config error.
6.2 What the broker enforces
The capability model itself — identities, minting, delegation, revocation — is described in capabilities.md. This section lists the shipped, code-enforced properties, the how behind §6.1:
- Capabilities are mTLS-CN-bound and non-transferable. Every token carries an intrinsic check
caller == bound_agent(internal/cap/token/token.go); the broker authenticates the caller from its verified client-cert CN and fails closed without one (ca.RequireAgent, called first on/request,/llm, and every brokered tool route). A stolen token is useless without that agent’s in-container private key, which is generated in the container and never leaves it. - Per-call epoch + revocation is the real cut, not the TTL. A generous grant TTL (24h by default;
broker.grant_ttloverrides it) is only a backstop; on every call the broker re-checks the live revocation set andMinEpoch(/revokeand/bump-epoch, persisted across restarts and seeded at construction). Revoking an agent or bumping the epoch denies its outstanding tokens immediately, this is verified for both the/llmproxy and first-party tool calls. - Constraints can only narrow, and only the broker mints.
/requestvalidates each requested constraint against the tool’sAllowedValues(fail closed) and bakes it into the signed token as an equality check on that request parameter (internal/cap/token), so the minted capability is usable only for that exact value. The broker is the sole minter, there is no offline way to widen a token, and any tampered token fails Ed25519 signature verification. - Forged identity headers are scrubbed. The broker deletes every inbound
X-Lever-*header before processing a tool call (internal/broker/gateway.go), then setsX-Lever-Calleritself from the verified CN, a jail agent cannot forge broker-internal context. - The admin surface is loopback-only.
/register,/revoke,/bump-epoch,/bootstrap,/epoch,/worker-ticketare unauthenticated and protected solely by binding to loopback.brokerctl.bindListenersbinds it at127.0.0.1, andBroker.ServeListenersthen refuses to serve unless the listener it was handed reports a loopback address — the one enforcement point, fail-closed, so any future caller that binds the admin listener elsewhere is rejected rather than trusted. The jail reaches the broker only via the separate mTLS jail listener, never the admin port. The routes that write host records for the operator or act for the operator (lever msg send’s operator note,lever worker resume, the chat page’s wake, the remote proxy’s agent-message match) are not on the admin port at all: they sit on a 0600 unix socket (.lever-state/operator.sock), andServeListenersrefuses any other listener type for it. - The jail listener’s
GET /versionanswers the host lever’s version and nothing else. Any live agent identity (the manager or a worker, no capability) gets{"version", "jail_abi"}: whatlever versionprints (the release and the build’s commit) and the host’s Jail ABI, solever-manager versioncan tell an agent whether its image matches the host. A request without a client certificate, or from a revoked identity, gets 403. The route is on the mTLS jail listener only, not the admin port or the unix sockets, and its answer carries no instance id, config hash or path (the loopback/epochkeeps those). It does tell an agent the host’s exact build, and so which fixes the host lever lacks. /bootstrapis single-use. The first manager-enrolment ticket latches the broker; every later/bootstrapreturns 403 (internal/broker/bootstrap.go).applytolerates that 403 so re-apply is idempotent, but the latch bounds manager-identity minting to one per broker process.- The api-key placeholder is a sentinel, not a credential. scion’s start-time auth gate needs some
credential before the container (and thus
lever-agent boot) can launch, so api-key mode sets a fixed placeholderANTHROPIC_API_KEY(sk-ant-placeholder…) as a Hub secret. It is not a real key: claude sends it asx-api-key, which the broker/llmproxy overwrites with the real Console key host-side. Projecting it to every container is safe precisely because the instance is uniformly api-key (§6.1). lever writes it withhub env set --secret --always, nothub secret set: both create the same Hub secret row, but only theenvform can set the injection mode, and a secret stored with scion’s default (as_needed) is filtered out of the container environment — present in the Hub, never delivered. The same--alwaysapplies to the non-secretLEVER_LLM_AUTHvariable. - Operator directives authenticate the requester; they mint no capability. A separate,
operator-signed channel lets an agent verify that an instruction genuinely came from the human
operator (its own SSH signing key, held to a similar host-side trust posture as the credentials
above — own-machine or hardware-backed by default). A consumed directive only ever hands the agent
a validated action descriptor; the agent still executes it through the ordinary capability path
described in this section, subject to the same
MayObtain-at-mint andtoken.Verify-at-call checks. See Operator directives (§11) for the full threat model and scope (delivery and verification are enforced; call-time enforcement is not).
External MCP servers (broker-fronted). A broker.tools entry with external: true is a
host server the broker fronts but does not spawn: it registers from config, is gated on the
broker’s mTLS listener like any third-party tool (token verified, then stripped — the server
never sees a capability), and has no per-server allow_ports hole and no .mcp.json pointing at the host alias. Three
boundaries:
gate: coarseis wholesale trust. One wildcard capability ({tool,"*"}) admits every MCP call the server exposes, including destructive ones. It is honored only for a declared-coarse tool — the broker picks the required op per tool, so a wildcard token can never satisfy afinetool — and the audit log records the real MCP tool called either way. Put sensitive servers behindgate: fine.- The loopback-backend guard closes a LAN bypass. The broker proxies host-side, so a
non-loopback
backendwould hand a jailed agent a path to other hosts through the broker, circumventing the jail’s LAN-drop egress. Config validation rejects it unless the tool setsallow_non_loopback: true(an explicit, reviewed opt-in). - A service-host tool reaches one pinned machine only. A tool with
host:goes to its service host over mutual TLS: the broker presents a client key the jail never sees and accepts only the server keylever.yamlpins, and the service host enforces its own ceiling of tools and operations and holds the credentials. See the threat model. - The gate protects the jailed agent’s path, nothing more. Any other host-local
process can still hit the server’s
127.0.0.1port directly — host processes are already inside the host trust boundary; the broker does not claim to sandbox them from each other.
Host-side tool credentials (github). The github tool holds its
credential on the host and never projects it. lever-tool-github reads a GitHub App private key
(regular file, mode exactly 0600, owned by the broker user) and mints 1 h installation tokens, each
narrowed to one repository and the permissions of one operation; it refuses to start when the key
or its -state directory is inside -tree. It is a first-party broker tool: the agent holds only a
capability token, which the broker verifies and forwards and the tool verifies again. See the
github tool guide. lever-tool-whisper holds no credential, but it is a
first-party host tool on the same terms: its transcribe operation reads WAV files from the tree
root’s .lever-files/whisper/ (which only the manager can write) for any agent holding the
capability. See the whisper tool guide. lever-tool-tts holds no credential
either; its only operation, voices, is read-only. See the tts tool guide.
In-jail hub reachability (residual). The capability broker above is the audited seam for agent lifecycle, but the in-jail scion Hub API itself remains reachable from inside an agent container, using that agent’s own scion token (distinct from §4.2’s host-only controller PAT — this is the per-agent token scion mints for status/heartbeat use). Two controls bound what that token can do:
- Dev auth is off, hardcoded. The hub always starts with
--dev-auth=false(internal/apply/run.go,HubServerOpts); no config field turns it on, and the scion-server step restarts a hub that was started without it. The hub authenticates people and lever itself only through assertions signed by a host-held key (§6.4), which no process in the jail can produce. - Every agent is stamped
--role baseline. A named scion role determines an agent token’s scopes. lever passes--role baselineonscion startfor every agent, manager and workers alike (internal/scion/lifecycle.go);scion.agent_roleoverrides the role for operators who need a different bundle. Baseline carriesproject:read,agent:status:update,agent:token:refresh,project:agent:notifyandagent:port:forward— heartbeat, self-token-refresh, and read/enumeration — but noagent:create,agent:lifecycle, orproject:secret:read. Those verbs 403 on the missing scope, including agentDELETE, which scion gates on lifecycle scope plus project isolation.
The stamp, and a hub-side ceiling behind it. Scion’s own default for an unspecified role is
full (agent create, agent lifecycle, project-secret-read). Its project ceiling (projectMax)
also defaults to full and narrows only when the project’s maximum agent role (the
scion.io/max-agent-role annotation) is set; the user ceiling is a pass-through. lever sets it:
the register-project step of lever apply writes the project’s maximum and default agent role to
the role it stamps, through the hub’s project-settings route under the host-side controller PAT,
and reads it back (internal/hubapi/ceiling.go; §4.4). The hub
then refuses a create that asks for a higher role, whoever sends it; a failed write fails the apply,
and lever doctor’s agent role ceiling row fails on an unset or different value. The ceiling
bounds creates only: it does not narrow a record that already exists (next paragraph). lever still
never relies on a scion default for the stamp: it decides
whether to pass --role by probing the installed binary for the flag, and if the probe cannot answer
the start fails rather than guessing. The probe does not memoise its answer: scion.source and
scion.binary name paths, so swapping the artifact behind one leaves the broker-identity hash
byte-identical and a long-lived broker survives it; a remembered answer could disarm the stamp and
the record guard below silently.
Stored roles are immutable, and lever refuses unrolled records. The role is written into the
agent record on the create path only; scion resume carries no role flag and the hub exposes no
route to set a stored role. The hub resolves a record with no stored role to full, at dispatch
and on every token refresh. So lever apply reads the hub’s record before it keeps an agent and
fails the bring-up when the record stores no role — including for an agent already running,
which refreshes its own token on the same rule. The ways out are to delete the agent so lever
recreates it with --role baseline (losing its conversation), or to stamp agentRole: baseline
into the stored records in ~/.scion/hub.db with the hub stopped (this only narrows).
lever doctor reports unrolled records (agent authorization roles check,
internal/cli/host/doctor_checks.go). A resume of a declared worker with no hub record falls through
to creation, which stamps the role, under the project ceiling above.
Expired agent tokens are healed, not widened. scion’s agent hub token lives 10 h, and
sciontool refreshes it 2 h before expiry with a timer that stands still while the host sleeps; an
expired token cannot refresh itself. lever apply and the broker’s hub-token watch (every 5 min,
governed by broker.auto_reenrol, audit op=hub-token) run scion reset-auth for an agent whose
token expired. The call runs host-side under the controller PAT; the hub mints the new token from
the agent’s stored role, and the container receives only that agent token, never a user or
controller token (internal/broker/tokenwatch.go, scion.Client.ResetAuth). lever doctor’s
agent hub tokens row reports an expired or overdue token. See
agent session heals.
Egress mode gives no reduction here: egress: closed still ACCEPTs loopback first specifically
so the in-machine scion hub keeps working (§2.2), so this path is reachable
identically under open or closed. The real (if narrow) bound is tenancy: this is a
single-operator, single-project instance — an operator-run manager and every worker already belong
to that same operator’s own instance, so an agent reaching this path has no cross-tenant blast
radius to escalate into (§8).
6.3 Leaf rotation and the re-read invariant
The mTLS identity this section rests on is a 24h leaf signed at enrolment; the short life bounds the exposure window of a leaked agent key to a day, backstopping the per-call epoch + revocation that is the real cut (§6.2). Every long-lived broker client must re-read the rotating leaf per TLS handshake. The lifecycle (enrolment, renewal, the two long-lived clients, and the failure mode when the invariant is broken) is on Agent identity & certificates.
6.4 The hub assertion key
Lever is the hub’s authenticating proxy (§4.2). Every hub
sign-in, whether lever’s own governance as lever-admin@lever.local, the controller minting its
PAT, or a remote login’s web session, is an assertion signed with one ES256 key,
.lever-state/hub-auth.key (0600, directory 0700). The lever CLI and the remote proxy read it;
nothing in the jail does. The hub holds only the public half.
- What it is worth. Admin on this instance’s hub, and only with a path to that hub: the hub
listens on the jail’s loopback, so a stolen key also needs a way into the jail. It is strictly
weaker than
.lever-state/session-secret, which lever already held: scion derives its agent and user token signing keys and its secret-encryption key from the session secret.hub-identity.jsonbeside it holds no secret. - Rotation. Delete the file and run
lever apply. Lever makes a new key, installs its public half, and restarts the hub once; it finds its hub users again by address.lever destroykeeps the key, since it is not tied to one hub database. - The remote proxy holds it too. The proxy signs only the hub addresses of the configured
logins (a positive allowlist) and never a reserved address (
lever-admin@lever.local,lever-controller@lever.local, any@localhostaddress), and it strips any client copy of the assertion header. The proxy is a host process the jail cannot reach; a bug in it, not an agent, is what this allowlist guards against.
The workstation-route residual. Lever runs the hub as a workstation hub, where scion’s
/api/v1/system/* routes accept any authenticated loopback caller, including an agent’s own
token, and run no role check. Three of them matter here:
PUT /api/v1/system/identityrenames scion’s dev user. On an instance an older lever brought up, that row is lever’s controller (§4.2). A fresh instance has no dev user, and the rename is a no-op. On a migrated one an agent can rename the controller between applies. Lever never records the address it finds there. Two repairs follow:- R1. If the agent took
lever-admin@lever.local, an assertion for the admin resolves to the controller’s row. The next apply sees that, restoreslever-controller@lever.localwith the controller PAT (the route accepts it), waits out the hub’s 60-second email cache, then asserts the admin again, which creates the real admin row, and demotes every other admin. - R2. Every apply re-pins the controller’s address to
lever-controller@lever.local, as the admin, or with the controller PAT if that fails.
Lever identifies the controller by its user ID, through its PAT, and asserts it only to mint or revoke a controller token, re-pinning the address and asserting together up to five times. An agent that renames the dev user in a loop can make such an apply fail, and the error says so, but it cannot delay a revocation: removed remote logins are refused at the proxy before any apply step and suspended at the hub before any controller work.
Meanwhile the remote proxy checks every login’s hub user against the ID lever recorded, so a rename aimed at a contact’s address costs that login (refused, and audited as
hub-session-refused), not the others. Lever invites every configured login before anything else, so a login’s address is taken by the right row before an agent can aim a rename at it.- R1. If the agent took
PATCH /api/v1/system/workstation-settingsand the identity route rewrite the jail’s~/.scion/settings.yaml. That cannot change the hub’s authentication: lever pins it through the environment the hub starts with, and these edits trigger no restart.POST /api/v1/system/fs/mkdircreates directories under$HOME. The public key lives under/etc/lever, root-owned, outside it.
The sender label. The hub stamps a message with user:<the sender's address at send time>,
and the broker’s message_verify accepts lever’s own sends only under the controller’s current
label, user:lever-controller@lever.local. On a migrated instance the label was the dev user’s old
address (usually user:scion@localhost). A message delivered before the migration but verified
after it carries the old label and answers unknown_sender; accepting an old label, which an agent
could influence, would weaken the partition verification relies on. Agents verify on receipt, so
only messages in flight across the upgrade are affected. When the controller’s label changes under
a running broker (a rename, then the next apply’s repair), the broker re-resolves it on a
verification miss, at most once every 30 seconds. So between an agent’s rename and the next apply,
lever’s own messages carry the label the agent chose, and message_verify accepts lever’s sends
under it. That window costs nothing to forgery: verifying a lever message still needs lever’s own
outbox record, hub addresses are unique, and a label that collides with a remote login’s sender is
refused. Every apply re-pins the address and warns when it changes again right after.