Skip to main content
This page documents MobKit v0.8.34 (mirrored from v0.8.34). MobKit ships an optional attribute-based access control (ABAC) layer for everything that flows through the console and observation surfaces. When it is off — the default — nothing changes. When it is on, every console caller only sees and operates exactly what their attributes grant: the agent sidebar, roster, topology, timeline, event streams, sends, and lifecycle actions are all filtered or gated per principal, invisibly. A restricted user doesn’t see denied agents or disabled buttons explained; they just get a console shaped to what they’re allowed. There are no roles. Every check evaluates the caller’s attributes (subject, group memberships) against the resource’s attributes (agent identity, role, labels) for one action — the closest thing to a role is a rule that bundles a group with a set of actions.

The model

  • Principal — the authenticated console caller: the subject (email or token sub) 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.
Evaluation is deny-by-default with deny-overrides: a matching 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 (via mob_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.
Upgrading changes what existing rules grant: an operator holding agent.view/agent.send on a coordinator now also holds those actions on every member that coordinator spawns. This is the intended default — agent-spawned workers are part of the parent’s work — but deployments with tight per-agent rules should review coordinator grants, and can add member-specific deny rules where inheritance is unwanted.

Configuration

Drop a config/access.toml next to your other config files — its presence opts the deployment in:
Enabling requires at least one admin subject — the config layer refuses any state that would lock every administrator out. Programmatic embedders use the builder instead:

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 the mobkit/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, and mobkit/console/query_timeline — frames for hidden identities are filtered out.
  • mobkit/console/send and /console/send — require agent.send on the target.
  • mobkit/agent_memory/remember and mobkit/agent_memory/forget — require agent.memory.write and agent.memory.delete respectively on the target identity. mobkit/agent_memory/recall and mobkit/agent_memory/manifest require both agent.memory.read and agent.view on the identity.
  • mobkit/memory/panel/* (the console Memory panel, read-only) — every returned record is filtered by its scope: identity-scoped records need agent.memory.read + agent.view on that identity, mob-scoped records need mob.memory.read, operator-scoped records need operator.memory.read, realm-scoped records and mobkit/memory/panel/dreams need an unscoped agent.memory.read grant, and quarantined records plus mobkit/memory/panel/quarantine need memory.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 — plain agent.view alone no longer reads any of it.
  • Lifecycle and runtime RPCs — mapped to agent.retire / agent.respawn / agent.reset / agent.spawn / runtime.admin per method.
  • Topology control — query visibility requires agent.view and topology.view per endpoint. Connect, disconnect, and reconnect require the matching topology.* grant on both endpoint identities for planning as well as applying. Multi-operation requests also require topology.bulk; the runtime still enforces its finite batch ceiling. Durable mutation audit and actor/principal attribution require the separate topology.audit grant 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 — requires agent.view on that agent. /mob/events and /mobkit/mob_events/stream require mob.observe to open and additionally filter each event by agent.view on its source (see below).
Label- and role-based rules resolve against an attribute cache the runtime refreshes from every roster projection, so selectors keep working on surfaces that only carry an identity string.
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 with control_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.
A control listener without grants authorizes nothing. start_control_listener, the Rust builder’s control_listen, and the gateway --control-listen path bind with an empty table and refuse every request until scoped grants are installed. An empty table is not the same as an explicitly open authorizer: empty refuses everyone, while ControlAuthorizer::open() is a lower-level compatibility choice an embedder must name deliberately.
Embedders mount a table directly:
Use 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.
Three limits worth stating plainly:
  • A grant is checked per request, so a caller granted inject on 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.
  • wire is a trust-granting verb. A caller granted wire on 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; inject and lookup_member are the narrower verbs.