The model
- Principal — the authenticated console caller: the
subject(email or tokensub) plus the groups it belongs to. On an open console (require_app_auth = false) the principal is anonymous and only matches rules without subject constraints. - Resource — usually an agent: its identity, role, and labels (the same labels you already use for console grouping).
- Action — a verb from a fixed vocabulary:
- Rule — matches principals (
subjects,groups), actions (exact,agent.*, or*), and resources (agents,roles,match_labels). Resource selectors AND together; values within a list OR together. Empty selectors leave a dimension unconstrained.
deny rule always wins, then a matching allow rule allows, otherwise the check denies. Subjects listed in admins bypass rules entirely. A disabled config allows everything.
Spawn-lineage inheritance
Members spawned by an agent (viamob_spawn_member, delegate, and the other agent-facing spawn tools) inherit their spawning parent’s permissions. The spawn path records a spawned_by label as console metadata, and agent checks evaluate the member and its spawn ancestors: a rule that matches the parent — by identity, role, or labels — also matches the members it spawned, transitively. Deny-overrides is preserved across the chain, so a deny on the parent also denies its descendants, and a member-specific deny beats any inherited allow.
This is what makes agent-fanned-out workers governable without per-worker rules: granting agent.view on a coordinator lets the operator watch everything that coordinator spawns, and hiding a sensitive lead also hides its helpers. Lineage resolution is bounded (depth 8) and cycle-safe, and members can always carry their own labels or rules to override the inherited outcome (subject to deny-overrides).
Lineage is runtime-derived only: spawned_by and via_tool come from the spawn path’s own record of who performed the spawn. A spawned_by claim arriving through caller-controlled labels (spawn specs, roster labels) is stripped at every enforcement seam — an unverified claim could otherwise mint inherited visibility, or hide a member behind a denied parent. The lineage registry is in-memory: it repopulates as members are spawned and does not survive a runtime restart.
Configuration
Drop aconfig/access.toml next to your other config files — its presence opts the deployment in:
Live administration from the console
Administrators get an Access entry in the console workbench (it never appears for anyone else). It edits the live configuration through themobkit/access/* RPC methods — group membership, rules, admins, and the enforcement toggle all apply on the next request, no restart, and persist back to access.toml:
All write methods require
access.admin (or admin standing) and return the new config revision. Denied operations everywhere surface as JSON-RPC error -32030 with data.kind = "access_denied", or HTTP 403 on REST/SSE surfaces.
Bootstrap: a fresh controller (disabled, no admins) lets any authenticated console user open the Access panel, name admins, and enable enforcement. The moment admins exist, only they (and access.admin grantees) can administer.
What gets enforced
/console/experience— agents the caller can’t view vanish from the sidebar, roster, topology, identity status; per-agent affordances (can_send_message,can_retire,can_respawn) and runtime capabilities are intersected with the caller’s grants, so the stock console UI degrades invisibly. Affordances are resolved per agent: a caller who can view and retire an agent but not message it gets a disabled composer on that agent while keeping the retire action — having no send grant anywhere does not flip the whole console to deployment read-only./console/timeline, timeline streams, andmobkit/console/query_timeline— frames for hidden identities are filtered out.mobkit/console/sendand/console/send— requireagent.sendon the target.mobkit/agent_memory/rememberandmobkit/agent_memory/forget— requireagent.memory.writeandagent.memory.deleterespectively on the target identity.mobkit/agent_memory/recallandmobkit/agent_memory/manifestrequire bothagent.memory.readandagent.viewon the identity.mobkit/memory/panel/*(the console Memory panel, read-only) — every returned record is filtered by its scope: identity-scoped records needagent.memory.read+agent.viewon that identity, mob-scoped records needmob.memory.read, operator-scoped records needoperator.memory.read, realm-scoped records andmobkit/memory/panel/dreamsneed an unscopedagent.memory.readgrant, and quarantined records plusmobkit/memory/panel/quarantineneedmemory.quarantine.review. The review grant only opens the quarantine surface — each queue record and pending promotion is still filtered by the per-scope read grants above on its (target) scope. Memory content is more sensitive than roster visibility — plainagent.viewalone no longer reads any of it.- Lifecycle and runtime RPCs — mapped to
agent.retire/agent.respawn/agent.reset/agent.spawn/runtime.adminper method. - Topology control — query visibility requires
agent.viewandtopology.viewper endpoint. Connect, disconnect, and reconnect require the matchingtopology.*grant on both endpoint identities for planning as well as applying. Multi-operation requests also requiretopology.bulk; the runtime still enforces its finite batch ceiling. Durable mutation audit and actor/principal attribution require the separatetopology.auditgrant on every endpoint in a returned record. Cross-authority mutation is separately gated and is available only to a same-process host coordinating both runtimes; the authority-local JSON-RPC surface fails it closed. /agents/{id}/events— requiresagent.viewon that agent./mob/eventsand/mobkit/mob_events/streamrequiremob.observeto open and additionally filter each event byagent.viewon its source (see below).
Migration — memory read actions. Recall used to ride bare
agent.view. Configs written before agent.memory.read existed (no *.memory.* action mentioned in any rule) keep working: on load, every rule matching agent.view is extended to also cover agent.memory.read — allow and deny rules alike — and a warning is logged recommending explicit rules. The rewrite is materialized into the persisted config, so it happens once. A config that mentions any memory action anywhere is taken literally.mob.observe and per-agent filtering
mob.observe gates access to the whole-mob event surfaces — /mob/events, /mobkit/mob_events/stream, and mobkit/mob_events/query / subscribe. It does not override per-agent agent.view: events flowing through those surfaces are still filtered per agent, so a caller with mob.observe but not agent.view on agent X sees neither X’s live events nor X’s lifecycle entries. (Mob-level structural events with no agent attribution flow on the mob.observe grant alone.) To observe the entire mob, grant mob.observe together with agent.view on *.
/blobs/{id} is a capability surface
/blobs/{id} serves content-addressed (sha256:…) blobs. Blobs are deduplicated by content hash and have no single owning agent, so they are gated by console authentication plus hash unguessability rather than per-agent ACL. A 256-bit content hash is only learnable through a surface the caller is already allowed to see (e.g. a visible agent’s timeline frame), so a denied caller cannot enumerate another agent’s images. Do not treat blob URLs as a per-agent confidentiality boundary; treat them as capability URLs.
Relationship to authentication
Access control layers on top of console authentication (require_app_auth + the email allowlist): authentication decides who may reach the console at all, ABAC decides what each of them sees and does once inside. Identity comes from the validated bearer token (email or sub claim). Running with access control enabled but authentication off is possible (anonymous callers match only unconstrained rules), but for per-user policy you want auth on.
Cross-mob control grants
Everything above governs the console plane: human callers reaching agents through the console, REST, and RPC surfaces. The cross-mob control channel is a separate plane with a separate policy, and the two never see each other’s rules. That channel is the TCP/UDS listener a gateway binds withcontrol_listen / --control-listen. Peer gateways drive four verbs across it:
A control grant binds one calling gateway - identified by its Ed25519 pubkey, the same one
mobkit/peer_pubkey reports - to the verbs and the members it may reach. Anything outside the grant is refused before the request reaches your mob, so an ungranted caller never learns whether the member it named exists.
How a caller is identified
Callers sign every control request with their gateway keypair. The signature covers the request’s semantic fields plus the audience (the mob id the caller dialed), so it cannot be re-pointed at another member, another verb, or another gateway. The receiving side matches the verified pubkey against its grant table. This is independent of the response signing you may already be using:pubkey on a contact entry authenticates the answers you receive, a control grant authorizes the requests you serve. They compose, and either can be used alone.
Configuring grants
verbs = ["*"] grants all four. An unknown verb, a missing or empty verbs list, an undecodable pubkey, or two labels sharing one pubkey are all config errors - the table refuses to load rather than granting something the operator did not write.
A caller entry accepts only pubkey, verbs, and members; any other key is a config error too. That is deliberate rather than pedantic: member = ["bob"] (singular) would otherwise leave members absent, and an absent members means every member - the typo would quietly widen a one-member grant to the whole mob. Put notes in # comments.
Refusals come back with a stable code: unauthenticated_caller, invalid_caller_credential, invalid_caller_signature, caller_not_granted, verb_not_granted, member_not_granted, audience_mismatch. On the calling side they surface as a distinct error from an ordinary rejection, because the remedy is different: nothing about the request is malformed and retrying will not help until the peer operator widens the grant.
Embedders mount a table directly:
ControlAuthorizer::with_grants_for_audience(table, "my-mob-id") when one caller holds grants on several of your gateways and a captured request must not be replayable across them; the mob id is the one peers use for this gateway in their contact directories.
The SDK-driven
rpc_gateway accepts the same TOML under runtime_options.control_grants_toml; the conventional mobkit_gateway reads [control_grants] beside [mobs] in config/contacts.toml. The Rust builder accepts a ControlGrantTable through control_grants. Every configured-listener path binds the table to the runtime’s mob id as the signed audience. ControlAuthorizer::from_toml still distinguishes an absent section from an empty section for embedders that explicitly choose compatibility behavior.- A grant is checked per request, so a caller granted
injecton a member may inject repeatedly. Grants bound who and what, not how often. - The channel has no replay cache: a captured request stays valid at its audience until the grant is withdrawn. Each request carries a client-minted nonce inside the signed material, so a future replay guard has something to key on, but none is enforced today.
wireis a trust-granting verb. A caller grantedwireon a member may install a trusted peer descriptor of its choosing on that member - including one naming a third party’s key. Grant it only to peers you would let choose who your member talks to;injectandlookup_memberare the narrower verbs.
