Skip to main content
Comis governs what an in-process agent may do with a small, closed set of capabilities — permission tokens like orch:spawn and orch:cron — checked by a single gate on every privileged action. Capabilities are deliberately orthogonal to the gateway’s network scopes, and the control plane is unreachable to non-admin agent turns by construction. This page describes the model; the operator-facing dial that grants capabilities is the Autonomy profile.
Why a separate axis from gateway scopes. External callers authenticate to the gateway and carry a network scope (rpc, admin, mcp-client). An agent’s own tool calls, by contrast, reach the RPC dispatcher directly, in process — they never pass through the gateway scope check. That in-process path is exactly the bypass the capability gate closes: the gate lives at the handler boundary that every call reaches, scope check or not.

The Closed Capability Union

Capabilities are a closed string-literal union — one member per orchestration surface. The runtime list is the single source of truth; the static type is inferred from it, so the two can never drift. Every member is namespaced orch:. None of them is, or implies, a gateway scope — the two sets are disjoint, and an architecture test asserts the intersection is empty.

The Curated Tool-Capability Surface (tool.invoke)

An autonomous orchestrate script does not call RPC methods directly — it calls named tools through tool.invoke over the lease-authenticated capability socket. A second curated map — the tool→capability map — decides which tools that surface exposes and which capability each requires. It is default-deny: a tool that is not on the map has no capability and is undispatchable. The map above is representative; the authoritative list is TOOL_CAPABILITY_MAP in the daemon (the same table the dispatch gate, the lease audience, and the generated in-jail SDK all read, so they cannot drift). Two invariants hold by construction, asserted by architecture tests:
1

No cap-mapped tool is denylisted

Every tool on the map is checked against the never-delegate denylist at module load. The administrative/destructive tools (mcp_manage, mcp_login, gateway, the *_manage family) are never on this surface — they stay unreachable, and deny-by-origin covers the control plane behind them.
2

An unmapped tool is undispatchable

A tool.invoke for a tool absent from the map raises CapabilityDeniedError before any lease is even consulted — default-deny by absence. The MCP export-policy is not the gate (it would admit the wrong set); the cap-map plus the denylist are the only gates.

Connected MCP Tools (orch:mcp)

When the operator connects MCP servers, their tools can be surfaced into a jailed orchestrate script as comis_tools.mcp.<server>.<tool>(), gated by a dedicated capability, orch:mcp. orch:mcp is a floor capability held by default on the autonomy-bearing profiles (standard/unattended/max) — exactly like orch:write. Holding the cap is not the reachability gate: a call is reachable only if the specific {server, tool} pair is on that agent’s inbound allowlist, which is empty by default. So a fresh agent holds orch:mcp yet reaches no MCP server until the operator allowlists one — granting the cap by default opens nothing. Reachability is therefore default-deny at the allowlist layer (the cap being held is the same floor-cap + surface-opt-in shape as orch:write + its writeSurfaceEnabled gate):
1

The capability grant (floor)

orch:mcp is in the standard/unattended/max floor set (the assistant profile, which has autonomy off, holds it only via an explicit autonomy.mcp.enabled: true opt-in). The cap being present is necessary but not sufficient — it authorizes the mcp surface to exist, not any specific server.
2

The per-agent inbound allowlist (the operative gate)

A {server, tool} pair absent from autonomy.mcp.allow — or on a server with no explicit inbound classification — is denied at the daemon-side executor. autonomy.mcp.allow defaults {}, so an unlisted server or tool is unreachable by absence. This is the layer that keeps the surface dark by default.
The MCP call itself runs daemon-side (on the daemon’s network, like web_fetch), so the jail stays --unshare-net; the result re-enters wrapped as untrusted data. The MCP control plane stays firmly on the far side of the gate: mcp_manage/mcp_login are on the never-delegate denylist, and the underlying mcp.* management methods are refused to any agent-origin call — so a jailed script can invoke an allowlisted server’s tool but can never connect, disconnect, or authenticate a server.
orch:mcp is not the mcp-client gateway scope. They share the letters “mcp” and nothing else: orch:mcp is an orch:-namespaced capability gating an agent’s inbound use of connected MCP tools from inside the jail; the mcp-client gateway scope governs an external network caller. The two axes stay disjoint — the emptiness of their intersection is architecture-tested, exactly as for every other capability.

The write and message surfaces are new typing, not new capabilities

The typed mutation surfacecomis_tools.write(...) and comis_tools.message_send/reply/react(...) — shares its posture with orch:mcp, and it is worth stating plainly: orch:write, orch:message, and orch:mcp are all floor capabilities, on by default in standard and up (only assistant withholds them). The capability being held is never the operative gate — a separate per-surface opt-in is: autonomy.write: true for the typed write surface, and the per-server autonomy.mcp.allow allowlist (default {}) for connected MCP tools. Calling any of these capabilities “default-off” would be inaccurate — the honest statement is that each surface is default-deny (inert until its opt-in is set), while the underlying floor capability is held.

Wake-Gate Suppression Bound

A scheduled cron job may carry a wake-gate — a pre-run script that decides whether that fire invokes the model. The gate is a caller of the same curated tool.invoke surface above, and it stays inside the same envelope as any other autonomous script:
  • No new capability. The gate runs under the agent’s own resolved autonomy capabilities in the standard jail. Authoring and running a gated job is the existing orch:cron capability, and the gate’s tool reach is exactly the agent’s granted orch:* set (through the tool-capability map) — never a gate-specific grant. A job cannot use a gate to reach a tool the agent does not already hold.
  • Per-fire least privilege. Each fire mints a fresh attenuated lease under its own root, so one job’s gate can never act for — or suppress — another job.
The gate is an efficiency layer, not a guaranteed-fire security control, and it matters not to confuse the two. A gate decides whether a fire wakes the model; it is not a mechanism that guarantees a monitor fires. That distinction bounds the worst case a hostile or broken gate can reach:
1

It cannot escalate

A gate holds no capability the agent lacks, so it can reach no tool and no surface the agent could not already reach directly. It is also fail-open: an errored, timed-out, or over-capped gate wakes the model rather than silently swallowing the fire.
2

It cannot suppress another job

The only suppression a gate can cause is on its own job — an explicit skip verdict on its own fires, a self-inflicted denial of service. Because each fire runs under its own per-fire root, a gate never reaches a different job’s fires or another agent’s runs.
3

A self-DoS is visible, not silent

A gate that skips every fire shows up as a 100% skip-rate in the comis system-health health report, so a monitor gated into silence is legible rather than a quiet gap. Each denied tool call inside a gate is the same content-free CapabilityDeniedError audit signal as anywhere else.

One Gate, No Bypass, No Wildcard

A single predicate decides every privileged action. It is a plain membership test:
Two properties make least-privilege hold by construction:
1

No wildcard branch

Unlike the gateway’s scope check — which has an asterisk-implies-all rule — the capability predicate has no lattice and no catch-all. A held set is exactly the capabilities it lists; no entry (an asterisk string included) confers any other capability. No capability can ever imply admin, rpc, or “all”.
2

Disjoint from network scope

Capabilities are orch:*; gateway scopes are rpc | admin | mcp-client. Because the two vocabularies do not overlap, holding a capability can never be mistaken for holding a scope, and vice versa.
When a caller lacks the required capability, the gate raises a CapabilityDeniedError discriminated by kind: "capability_denied", so the denial is recorded as a content-free security signal in the audit trail.

Deny-by-Origin on the Control Plane

Capabilities govern what an agent may do. A second, stricter rule governs the control plane: a non-admin agent turn may never reach it, regardless of capabilities. Admin-only handlers (the ones that manage secrets, tokens, config, channels, and the other privileged-surface methods) reject a non-admin agent-origin call outright — before any capability is even consulted. An admin-trust agent turn (an explicit operator grant) is the exception: it inherits the admin user’s control-plane access (detailed in the trust-tier note below). The deny-by-origin set is exactly the true control plane — the contracts that expose an admin route without also exposing an rpc route. A multi-scope contract is excluded because its RPC route is intentionally agent-reachable; its handler owns the narrower caller-vs-operator authorization rule. The orchestration surface an agent legitimately reaches (its granted orch:* methods, and its own session reads) is not in the deny set. A handful of methods stay control-plane-only even within otherwise-agent-facing namespaces: the message subset that is not part of orch:message (message.edit/delete/fetch/attach) and the lifecycle ops that delete or export an arbitrary session (session.delete/export/reset_conversation). This is sound because of how agent origin is established:
1

Internal markers are injected in-process only

The legitimate path that tags a call as agent-originated (injecting _agentId and _capabilities) runs inside the daemon, never across the network boundary.
2

Inbound internal fields are stripped at the gateway

Every field whose name begins with an underscore — the whole internal-field registry, including _agentId, _capabilities, and _trustLevel — is stripped from external WebSocket and REST callers before dispatch. An external caller therefore cannot forge agent origin or handler trust. After route admission, the gateway derives admin trust from the authenticated context independently of which accepted route admitted a multi-scope method.
3

Presence becomes an unforgeable signal

With inbound markers stripped, the presence of _agentId on a call is a sound, unforgeable “this came from an agent” signal — which is what lets the admin-only handlers deny-by-origin safely.
Deny-by-origin is trust-tiered. A control-plane method rejects an agent-origin call from a non-admin turn (guest/user trust, or a turn with no resolved trust) — the confused-deputy floor: a low-trust or prompt-injected agent can never reach the control plane through the in-process dispatcher. An admin-trust agent turn — one acting for a user the operator explicitly granted admin via elevatedReply.senderTrustMap (resolved per-message) — INHERITS that user’s control-plane privileges and is allowed through to the admin handler (which re-checks admin trust, defense-in-depth).The trust signal is sound: _trustLevel is in the internal-field registry (stripped from external callers, like _agentId) and is re-injected in-process from the framework request context after the call’s own params, so neither a tool nor the agent can forge it; an absent trust resolves to non-admin (denied). Admin is reached only via an explicit operator grant, never a default. This gate reads only the three authority labels admin / user / guest: an unmapped sender falls back to elevatedReply.defaultTrustLevel (itself defaulting to external), and every label outside those three — that external default, a disabled elevatedReply block, or one of the free-form labels used for model/prompt routing — lands on the non-admin user. An unresolved or anonymous sender (chat: / unknown:) lands on guest.That grant is operator-file-only: elevatedReply.senderTrustMap and elevatedReply.defaultTrustLevel cannot be set by any runtime RPC — not config.patch (the whole agents.* prefix is immutable there), and not agents_manage / agents.create / agents.update, which otherwise legitimately write agent config (name, model, budgets, autonomy tuning, tool toggles). So an admin-trust agent can never raise its own sender’s trust, or grant admin to another sender, through a self-change; only editing the config file and restarting the daemon can. Matching is on presence, not truthiness — sending either key at all in a runtime call is the operator-only action, and the refusal is audited (outcome: "failure", naming the refused fields). The same file-only rule covers the sandbox and command-allowlist switches — see the terminal driver’s security model.

How Capabilities Get Granted

An agent does not request capabilities; the operator grants them through an Autonomy profile. The profile resolves to a concrete capability set, those capabilities are injected onto the agent’s in-process calls, and each privileged handler checks the one it requires. The mapping from method to required capability is fixed in the daemon — an agent cannot raise its own grant.

Autonomy

Named profiles, the zero-config default, and the legible degrade that grant and bound capabilities.

Security Model

The capability axis in the context of the full input/skill/execution/output defense layers.

Approvals

The escalation gate for outward, hard-to-reverse actions.

Audit

The content-free trail that records capability denials and origin rejections.