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.