Capabilities · 7. Who is calling
Ten controls decide who may talk to your daemon and what they may do once they are in. Only one of the ten refuses a caller it does not understand; the rest either name the caller, describe the caller, or log what a stricter configuration would have refused. Knowing which is which is the whole content of this chapter.
This chapter is explanation. It says why each control exists, what it changes for you, and where it stops. It does not restate scope lists, route tables or token formats; those are reference and they already exist. The plane described here is the Crux Daemon, not the VaultCrux Frontdoor, which has its own auth story on different ports.
7.0 The ten controls, and what each one actually stops
Read this table before the explainers. The right-hand column is the one that matters, because several of these controls are commonly assumed to do a job that a different one does.
| # | Control | Status | What it actually stops |
|---|---|---|---|
| 7.1 | Auth modes and the fail-closed start | SHIPPED, no default | A misconfigured daemon from booting at all |
| 7.2 | Deny-by-default route authorisation | FLAG CORECRUXD_ROUTE_AUTH, default shadow | Nothing, until you set it to enforce. In shadow it logs the denial and serves the request |
| 7.3 | The scope vocabulary | SHIPPED | Nothing on its own. It is the vocabulary the other controls speak |
| 7.4 | Passports | SHIPPED as records | Nothing. It names an actor; it does not authenticate one |
| 7.5 | Passport revocation | FLAG CRUX_PASSPORT_REVOCATION, default on | A revoked identity's MCP tool calls, and only those |
| 7.6 | Passport mint requests | FLAG CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS, default off | An agent from issuing itself an identity without a human |
| 7.7 | Identity continuity | FLAG CORECRUXD_FEATURE_IDENTITY_CONTINUITY, default off | History being lost when an identity is split, merged or renamed |
| 7.8 | Credential rails | SHIPPED | An MCP listener from booting with an unparseable token registry |
| 7.9 | RCX capability tokens | SHIPPED as crate and router, inert when unconfigured | A tool call whose token does not carry the capability, and a token a verifier does not fully understand |
| 7.10 | Agent passports and scope aliasing | FLAG CORECRUXD_AGENT_PASSPORTS, default off | One agent reading another agent's private facts under a stable identity |
Grounding for each control is in that explainer's closing line rather than in a chapter-level list, so the link that supports a claim sits next to the claim.
7.1 Deciding what proves a caller is who they say
Status SHIPPED, with no default value: the variable is required. Reached through the environment variable
CORECRUXD_AUTH_MODE; every HTTP, gRPC and MCP route. Who it is for operators.
What it does. The daemon accepts one of four answers to "what authenticates a caller": nothing, an unverified client header, an HS256 JWT, or a JWKS/OIDC JWT. It refuses to start if you have not answered, and refuses to start separately if your answer is unrecognised, so a typo aborts the boot rather than falling back to something permissive. It also refuses to bind a non-loopback address while in a development mode unless you set an explicit override.
Why it works this way. The rejected alternative is the ordinary one: pick a safe-sounding default and let operators tighten it later. That design turns a typo into a silent downgrade, and it makes "what is this daemon actually enforcing" a question you answer by reading code rather than by reading your own config. Removing the default costs you friction on the very first run and buys you a permanent guarantee that the mode in your environment file is the mode in force. The trade accepted is that the daemon cannot be started by someone who has not made a decision.
What changes for you.
- As an operator: you make one decision before first boot, and thereafter a misconfiguration is a startup failure you see in seconds rather than an open port you discover later.
- As an agent: nothing changes. Whatever the mode, you present the credential that mode expects, or none.
What it does not do.
- It does not make the shipped example configuration safe to deploy.
config.example.envsetsCORECRUXD_AUTH_MODE=off, which yieldsscope_bypass: true: every scope check on every route passes unconditionally, and the passport id header is trusted verbatim. The only thing between such a daemon and the network is the loopback bind default. Treat that file as a first-local-run convenience, not as a deployment starting point. dev_scopesdoes not authenticate either. The caller states its own permissions in a header and nothing verifies them. It is a development affordance, not an access control.- Choosing a JWT mode authenticates the caller. It does not decide which routes that caller may reach; that is 7.2.
Turn it on. Set CORECRUXD_AUTH_MODE=jwt_hs256 or jwt_jwks before the daemon leaves your machine. There is no default to fall back on.
Where the detail lives. The four modes, the aliasing and the scope_bypass warning: Daemon 14.1. Startup refusal: main.rs:304.
7.2 Refusing a route nobody classified
Status FLAG
CORECRUXD_ROUTE_AUTH, defaultshadow. Reached through middleware on every HTTP route. Who it is for operators.
What it does. Behind CORECRUXD_ROUTE_AUTH, which defaults to shadow, a middleware checks every request against a declared contract keyed on the matched route template, and a route with no declared contract fails closed with 403. In the default shadow setting it does the whole evaluation, emits a structured warning naming the mismatch, and then serves the request anyway. Set it to enforce and the same evaluation becomes a refusal.
Why it works this way. Deny-by-default is the correct posture and it is also the posture that breaks an existing deployment the moment a single route was missed. Shipping it in shadow first lets an operator run the enforcement logic against real traffic and read the warnings before anything is refused, which converts a risky flag flip into a boring one. The trade accepted is uncomfortable and worth stating plainly: the safe-sounding default is the one that enforces nothing, so a daemon that has never had this variable set is running with route authorisation observed rather than applied.
What changes for you.
- As an operator: in
shadowyou get an inventory of every request that a stricter daemon would have refused, before you refuse any of them. Inenforcean unclassified route returns 403 instead of running. - As an agent: in
enforce, a 403 from this layer means the route was never classified for your scope set, which is a configuration answer rather than a retry-with-different-arguments answer.
What it does not do.
- In the default mode it does not deny anything. A green log with
route_auth_shadow_mismatchwarnings in it is a daemon that is serving requests it has decided are unauthorised. - It does not authenticate. It authorises whatever principal 7.1 produced, including the empty, bypassed principal that
offproduces.
Turn it on. Set CORECRUXD_ROUTE_AUTH=enforce once your logs are free of shadow mismatches.
Where the detail lives. The middleware, the modes and the shadow default: Daemon 7.4. The default itself is logged as a known defect in Daemon 16.9. Implementation: route_auth.rs:607.
7.3 One permission vocabulary across three protocols
Status SHIPPED. Reached through HTTP headers, JWT claims and MCP bearer tokens. Who it is for operators and agents.
What it does. HTTP, gRPC and MCP all express permission in the same scope vocabulary: core read and write scopes, entitlement-shaped capability scopes for Pro surfaces, a separate MCP capability ladder, and a namespace for sync peers. A route is classified once and that classification is what every protocol consults.
Why it works this way. The obvious alternative is one permission model per protocol, which is what most daemons that grew a second transport end up with. It fails in a specific way: an operator who has reasoned carefully about HTTP permissions discovers that the MCP port answers to a different model with different names, and the two drift apart in opposite directions. A single vocabulary makes "what can this caller do" a question with one answer. The cost is that the vocabulary has to be wide enough to describe surfaces that are not shaped alike, so some scope names read as odd on some transports.
What changes for you.
- As an operator: one mental model covers three listeners, and a scope you grant means the same thing wherever it is presented.
- As an agent: the permission you were refused on one transport will be refused on the others; switching from HTTP to MCP is not a way around a denial.
What it does not do.
- It does not itself enforce anything. It is the vocabulary that 7.1 populates and 7.2 checks. Under
AUTH_MODE=offthe scope set is empty and every check is bypassed, so a complete and correct scope model is present and inert. - It does not describe capability tokens. Those are a separate model with separate semantics, in 7.9.
Where the detail lives. The complete scope list and how scopes are checked: Daemon 7.2 and Daemon 7.3. The per-route auth column is explained in API 0.4.
7.4 Naming the actor, which is not the same as proving one
Status SHIPPED as records. Per-request cryptographic enforcement is not wired. Reached through MCP
issue_passport,get_passport,get_agent_identity,resolve_principal;/v1/passports; Console → Trust › Passport. Who it is for operators and agents.
What it does. A passport is a named identity record carrying an Ed25519 key, a category, a sponsor, a receipt-count reputation tier and a live presence signal, resolvable to a mediator-facing principal. It is the label attached to stored work, so a fact written six months ago has an author rather than an anonymous origin.
Why it works this way. Attribution and permission are different problems on different timescales, and systems that fuse them are bad at both. You want to know who wrote something long after the session that wrote it has ended, and you want to constrain what a particular agent may do in the next thirty seconds. A durable record answers the first well precisely because it is durable and inert. A short-lived signed grant answers the second, and that is 7.9. Splitting them means a passport can be long-lived, human-readable and safe to log, because possessing knowledge of it grants nothing.
What changes for you.
- As an operator: your fleet's stored output has named authors, and reputation tiers give you a coarse standing signal derived from receipt count rather than from self-declaration.
- As an agent: you have a stable identity across sessions, and your private facts are scoped to it when 7.10 is on.
What it does not do.
- Passports are records, not per-request credentials. There is no challenge, no request-signing scheme and no proof-of-possession over the passport key anywhere on the HTTP or MCP request path. Nothing verifies that the caller in front of you now holds the key in the record.
- It follows that a passport id in a log line is not evidence of who acted. It is evidence of what the request claimed. Under
AUTH_MODE=offthe passport id header is trusted verbatim; under a JWT mode the JWT is what did the authenticating, and the passport is the name written next to the result. - Reputation tier is a receipt count, not an assessment of conduct. It rises with volume.
Where the detail lives. What a passport record contains, the two passport stores and the tier ladder: Daemon 14.2. Routes and presence: API 5.2. Record shape: passports.rs:62.
7.5 Taking an identity away
Status FLAG
CRUX_PASSPORT_REVOCATION, default on when unset. Reached through MCPrevoke_passport. Who it is for operators.
What it does. Behind CRUX_PASSPORT_REVOCATION, which is on when the variable is unset, a passport can be terminally revoked with a reason by itself, by its sponsor or by an elite-tier caller. A revoked caller is then refused every MCP tool outside a two-tool read-only allowlist, which exists so the revoked agent can discover that it has been revoked and why, rather than meeting an unexplained wall.
Why it works this way. Revocation supersedes rather than deletes, for the same reason the fact store supersedes rather than overwrites: the question asked after an incident is "when did this identity stop being trusted, and who decided", and a deleted record cannot answer it. Leaving two tools open to a revoked caller is a deliberate choice against the tidier design of refusing everything, because an agent that cannot read its own status will retry, escalate and generate noise instead of stopping.
What changes for you.
- As an operator: you can cut off a compromised or retired agent's MCP access without losing the record of what it did or why it was cut off.
- As an agent: if you are revoked you can still read your own passport and learn the reason, and every other tool refuses.
What it does not do.
- It is MCP-plane only. Enforcement lives in the MCP dispatcher. A revoked passport's HTTP requests are unaffected by this control.
- It fails open. If the passport record cannot be read, the call proceeds. An unreadable store is treated as "not revoked".
- Only the literal values
1andtruekeep it enabled once the variable is set. The resolver defaults to on when the variable is absent, but when present it tests for exactly1or a case-insensitivetrue. SettingCRUX_PASSPORT_REVOCATION=yesor=on, which reads to a human as enabling it, silently disables a security control. If you are going to set it at all, set it to1.
Turn it on. Leave CRUX_PASSPORT_REVOCATION unset, or set it to exactly 1.
Where the detail lives. Revocation semantics and the allowlist: Daemon 14.4. The fail-open behaviour is logged as a defect in Daemon 16.7. Flag resolution: dispatch.rs:115.
7.6 Making identity issuance a reviewed act
Status FLAG
CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS, default off. Reached through MCPrequest_passport_mint;/v1/passport/mint-requests; Console → Trust › Pending mints. Who it is for operators.
What it does. Behind CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS, default off, an agent files a request for a passport instead of issuing one, and a bound approver approves or rejects it from the console. This is the one flag in the daemon that changes MCP catalogue membership rather than only tool behaviour: with it on, the advertised tool list grows by one.
Why it works this way. Self-service identity issuance is fine while one person runs three agents and stops being fine the moment agents can spawn agents, because the population of identities then grows without anyone deciding it should. Putting a human in the loop at issuance is cheap, since issuance is rare, and it is the only point in the lifecycle where a human decision is both meaningful and low-volume. Gating it off by default keeps single-operator installations frictionless, and accepts that a fleet lead has to know the flag exists.
What changes for you.
- As an operator: new identities appear in a pending queue you clear, rather than in a list you audit afterwards.
- As an agent: you file a request and wait. You cannot mint your own identity while this is on.
What it does not do.
- It does not restrict what an existing passport may do. It gates the creation of new ones.
- The tool writes a pending record only. Passport creation is the approver's action, not a consequence of filing.
- With the flag off there is no queue, and issuance is whatever the deployment's scopes already allow.
Turn it on. Set CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS=1, then watch Console → Trust › Pending mints.
Where the detail lives. Request, approve and reject routes: API 5.3. The console queue: Operations 4.10. Tool handler: mint_request.rs:118.
7.7 The same actor under another name
Status FLAG
CORECRUXD_FEATURE_IDENTITY_CONTINUITY, default off, for split, merge and link-device; FLAGCORECRUXD_IDENTITY_LINKS, default off, for the proposed-link queue. Reached through MCPpassport_split,passport_merge,passport_link_device;/v1/identity/linksand/v1/identity/candidates; Console → Trust › Identity. Who it is for operators and agents.
What it does. Two mechanisms, both default off, answer "this is the same person or agent under a different identifier". Behind CORECRUXD_FEATURE_IDENTITY_CONTINUITY an identity can be split into two, merged with another, or linked to a new device, with history preserved rather than rewritten; all three require an explicit token_budget. Behind CORECRUXD_IDENTITY_LINKS a queue of proposed links is presented for a human to confirm or reject.
Why it works this way. Identity churn is normal: a developer changes machines, one agent is split into a planner and an executor, two tools turn out to have been the same actor all along. The naive fix is to rename a record, which quietly invalidates every attribution that pointed at the old name. Keeping split and merge as first-class operations that preserve history means the old attributions stay true and the relationship between identifiers becomes data rather than tribal knowledge. Link proposal is separated from link creation because a system that automatically merges identities it thinks are the same is a system that will eventually merge two that are not, and that error is very hard to unwind.
What changes for you.
- As an operator: device changes and agent restructuring do not orphan history, and proposed links arrive as a queue rather than as an automatic merge.
- As an agent: the continuity tools require a
token_budget, so the cost of an identity operation is declared before it runs.
What it does not do.
- Both mechanisms are off by default, so on a stock daemon neither the tools nor the routes are available.
- The candidate queue proposes. It never applies a link on its own.
- These operations rearrange identity records. They do not re-key or re-sign anything that was previously written.
Turn it on. Set CORECRUXD_FEATURE_IDENTITY_CONTINUITY=1 for the tools, CORECRUXD_IDENTITY_LINKS=1 for the link queue. They are independent.
Where the detail lives. Links and candidates: API 5.4. The three continuity tools: API 12.25. Link routes: identity_links.rs:97.
7.8 Four ways to arrive, one place they land
Status SHIPPED. The MCP token registry is fail-closed at startup. Reached through MCP bearer tokens;
/v1/auth/device/*,/v1/auth/tailscale/token,/v1/auth/whoami;corecruxctl login. Who it is for operators and agents.
What it does. Four rails let a caller prove itself and all four resolve to the same scope model: MCP agent bearer tokens from a registry built at startup, an RFC 8628 device-authorization login for callers with no host or environment access, a Tailscale identity rail that trusts a verified tailnet header behind tailscale serve, and read-only OAuth introspection for hosted clients. If the token registry is configured but unparseable, or a token fails the token policy, the daemon aborts rather than starting with MCP unauthenticated.
Why it works this way. Different callers have genuinely different access to secrets. A container has environment variables; a human at a terminal has a browser; an agent on a tailnet has a verified identity already. Forcing all three through one mechanism means two of them do something awkward. What must not fork is the thing the rail produces, so all four converge on the same scope set before any authorisation decision is made. The fail-closed registry is the important detail: a half-configured token list is the exact circumstance under which a permissive fallback would be most dangerous, so it is treated as a boot failure.
What changes for you.
- As an operator: you can onboard a human with a browser and a container with an environment variable without maintaining two permission models, and a malformed token list stops the daemon instead of silently opening the MCP port.
- As an agent: whichever rail you arrive on,
GET /v1/auth/whoamitells you the identity the daemon actually trusts for your request, which is the answer to use rather than the one you assumed.
What it does not do.
- An empty registry is legitimate and means anonymous MCP access, for single-user local runs. Empty is not the same as malformed: empty is allowed, malformed aborts. There is a dev override for the empty case, and it should not be set on anything shared.
- The Tailscale rail trusts a header injected by a local proxy. It is exactly as trustworthy as your confidence that nothing else can reach that listener.
- None of these rails verifies a passport key. They establish a principal and a scope set; see 7.4.
Where the detail lives. The rails and their routes: API 5.1. Token formats accepted: Daemon 7.6. Registry construction and its failure contract: agent.rs:36.
7.9 Capability tokens: the one control that fails closed
Status SHIPPED as crate and router. Enforcement is inert when no token is configured. Reached through MCP
tools/listfiltering andtools/callgating, JSON-RPC error-32030CAPABILITY_DENIED;/v1/rcx/publish/*. Who it is for operators and agents.
What it does. An RCX capability token is a signed statement of what its bearer may do. It can be attenuated as it is passed along, so a delegate ends up with strictly less authority than whoever handed it over, and delegation is domain-separated with macaroon-style caveats. Every daemon self-mints a free-local token at boot; that token filters the tool list the agent is shown and gates the calls it may make, so a denial is a CAPABILITY_DENIED error rather than a tool that silently misbehaves.
Why it works this way. This is the only control in this chapter designed around the assumption that the verifier may be older than the token. Version 1.1 tokens are contextual: they require recipient proof of possession, a verifier-issued nonce and an exact request context. A verifier that does not implement those rules does not skip them and continue; the generic verification path rejects a contextual token outright. The guarantee comes from strict field handling and the contextual gate rather than from a version number a lenient verifier could choose to ignore, which is the failure mode this design exists to prevent. The consequence, stated in the source as "mint before verify", is that a delegation-aware verifier must be deployed before any 1.1 token is minted, and that ordering constraint is the price of the guarantee.
Compare this with everything else in this chapter and the contrast is the point. Route authorisation defaults to logging what it would have refused. Revocation fails open on an unreadable record and covers one plane. Passports do not authenticate at all. Capability tokens refuse what they do not fully understand, and they refuse it at call time, per tool. If you are choosing which control to build a security argument on, this is the one with the shape you want.
What changes for you.
- As an agent: the tool list you are shown is already filtered to what your token permits, so an unavailable capability is absent rather than present and failing. A
-32030on a call is a permission answer, not a transient one; retrying is pointless. - As an operator: you can hand a delegate a strictly narrower token without minting a new identity, and a narrowed token cannot be widened by its holder.
What it does not do.
- With no router wired, enforcement is absent rather than denied. An unconfigured daemon does not refuse calls; it simply has nothing gating them. Absence of enforcement and presence of permission look identical from the outside, so verify that a router is wired before quoting this control as active.
- Filtering the advertised tool list is not the same as removing a tool. Shaping and filtering affect what is advertised; the gate at call time is what actually refuses.
- The token constrains capability. It does not authenticate the underlying identity, and it does not replace the scope model in 7.3; both apply.
Turn it on. No flag. Wire a router and configure a token; without one the crate is present and idle.
Where the detail lives. Token shape, attenuation and the fail-closed contextual gate: Daemon 14.6. Call-time enforcement, and the honest statement of what happens when no router is wired: API 14.3. The mint-before-verify contract: rcx-capability-token/lib.rs:14.
7.10 Writing the author into the fact
Status FLAG
CORECRUXD_AGENT_PASSPORTS, default off. Reached through every MCP fact write; also promotesissue_passportfrom hosted to local. Who it is for agents.
What it does. Behind CORECRUXD_AGENT_PASSPORTS, default off, the caller's raw agent name resolves to a passport id, and that id becomes both the actor recorded on each fact and the identity that private-fact scoping is evaluated against. A legacy alias set is carried alongside, so private facts written under the raw agent name before the flag was turned on stay visible to their owner afterwards.
Why it works this way. Turning on stable identities in a store that already has data creates a migration problem that most systems solve badly, by rewriting history or by stranding it. The alias set is the third option: the owner check accepts either the resolved passport id or the caller's old raw name, so nothing needs rewriting and nothing is orphaned. Visibility stays owning-identity-only in both cases, so the alias widens the owner's own view and never another agent's. Group sharing is deliberately not implemented, because the moment private facts can be shared with a group, "private" stops meaning one thing.
What changes for you.
- As an agent: your facts carry your identity as their author, and your private facts follow you across a flag flip rather than becoming unreachable.
- As an operator: attribution on stored facts becomes real rather than nominal, at the cost of a flag flip whose migration behaviour you should understand before you make it.
What it does not do.
- With the flag off, facts are written with no actor at all. This is not a degraded actor; it is absence.
- The alias set is back-compatibility only. A different passport matches neither the owner's id nor its aliases, and sees nothing.
- There is no group sharing. Private means one identity, by design.
Turn it on. Set CORECRUXD_AGENT_PASSPORTS=1. Read the aliasing behaviour first, because it governs what your existing private facts do afterwards.
Where the detail lives. Aliasing semantics and the deliberate absence of group sharing: Daemon 14.5. Private-fact scoping generally: Daemon 7.8. Visibility check: scope.rs:57.
Sources
Grounding for each capability is carried in that capability's closing line, next to the claim it supports, rather than gathered here; this set states a fact once and then links. Every source link resolves against origin/main of the Crux repository.

