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
dev-auth-off/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/config.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,/epochare 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. /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). - 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.
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 real hub always starts with
--dev-auth=false(internal/apply/run.go, theServerStart(..., DevAuth: false)call); no config field turns it on. - 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 explicit stamp is the only ceiling. 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 scion.io/max-agent-role annotation is set, which lever does not
set, and the user ceiling is a pass-through. So lever never relies on a scion default: 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/doctor_checks.go). A resume of a declared worker with no hub record falls through to
creation, which stamps the role; lever does not set the project annotation.
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.