Determine whether an incoming command from a specific sender is authorized to execute, based on access groups, authorizer entries, and allowlist policies.
Permission Gating
Controlling who can execute commands through layered authorization
Before any command handler runs, OpenClaw evaluates a multi-layer authorization pipeline. The CommandAuthorizer type, access group checks, allowlist policies, and plugin requireAuth flags work together to decide whether a sender is permitted to invoke a specific command. This page traces the full permission resolution flow from raw authorizer entries to the final allow/block decision.
📚 What is this & why does it matter?
Permission gating is the security boundary between command parsing and command execution. Every slash command and control command passes through this layer after being parsed and before any business logic runs. Understanding this module is critical for debugging "permission denied" errors, configuring access groups, building plugins with auth requirements, and auditing which users can invoke sensitive operations like model overrides or agent resets.
💡 Real-world analogy
Think of a building with multiple security checkpoints. First, a guard checks your badge against a visitor list (command authorizers). Then a second guard verifies whether your badge type grants access to this specific floor (access groups). Finally, certain offices have their own lock requiring a special key (plugin requireAuth). You only enter the office if you pass all three. Permission gating is those three checkpoints for every command.
Contents
Overview
CommandAuthorizer[] entries, useAccessGroups flag, allowTextCommands setting, senderIsOwner boolean, and optional allowlist configuration.
Are access groups enabled? Does any authorizer grant access? Should control commands be blocked? Does the allowlist policy permit the sender?
A boolean commandAuthorized and a shouldBlock flag. No side effects — purely evaluative. Blocking is enforced by the caller.
flowchart TD
MSG["Incoming command message"]
AG{"Access groups enabled?"}
MODE{"modeWhenAccessGroupsOff?"}
ALLOW_ALL["Return true (allow all)"]
DENY_ALL["Return false (deny all)"]
CONFIGURED{"Any authorizer configured?"}
NONE_CFG["Return true (no config = open)"]
CHECK_AUTH{"Any configured authorizer allowed?"}
GRANTED["commandAuthorized = true"]
DENIED["commandAuthorized = false"]
CTRL{"allowTextCommands && hasControlCommand?"}
BLOCK["shouldBlock = true"]
PASS["shouldBlock = false"]
MSG --> AG
AG -->|No| MODE
MODE -->|"allow"| ALLOW_ALL
MODE -->|"deny"| DENY_ALL
MODE -->|"configured"| CONFIGURED
CONFIGURED -->|No| NONE_CFG
CONFIGURED -->|Yes| CHECK_AUTH
AG -->|Yes| CHECK_AUTH
CHECK_AUTH -->|Yes| GRANTED
CHECK_AUTH -->|No| DENIED
GRANTED --> CTRL
DENIED --> CTRL
CTRL -->|"Yes && !authorized"| BLOCK
CTRL -->|Otherwise| PASS
The diagram above traces the complete authorization decision tree. The first branch checks whether access groups are enabled. When they are off, the modeWhenAccessGroupsOff setting determines the fallback behavior — allow all, deny all, or only evaluate configured authorizers. When access groups are on, the system always requires at least one configured-and-allowed authorizer entry.
Core Types
The permission gating layer relies on a small set of types that flow through the entire authorization pipeline. The CommandAuthorizer type is the fundamental unit, while CommandGatingModeWhenAccessGroupsOff controls fallback behavior.
// src/channels/command-gating.ts — CommandAuthorizer type
export type CommandAuthorizer = {
configured: boolean;
allowed: boolean;
};
export type CommandGatingModeWhenAccessGroupsOff =
| "allow" // All commands permitted when access groups are disabled
| "deny" // All commands blocked when access groups are disabled
| "configured"; // Evaluate configured authorizers even without access groups
| Type | Purpose | Key fields |
|---|---|---|
CommandAuthorizer | Represents a single authorization check result | configured — whether the authorizer has been set up; allowed — whether it grants access |
CommandGatingModeWhenAccessGroupsOff | Controls fallback when access groups are disabled | Union of "allow", "deny", "configured" |
AgentCommandOpts | Options passed to agent command handlers | senderIsOwner, allowModelOverride among others |
AgentCommandIngressOpts | Narrowed version of AgentCommandOpts with required auth fields | Same as AgentCommandOpts but senderIsOwner and allowModelOverride are non-optional |
The ingress opts type narrows optional fields to required ones, ensuring downstream handlers always have definitive authorization context:
// src/agents/command/types.ts — AgentCommandOpts and ingress narrowing
export type AgentCommandOpts = {
// ...
senderIsOwner?: boolean;
allowModelOverride?: boolean;
// ...
};
export type AgentCommandIngressOpts = Omit<
AgentCommandOpts,
"senderIsOwner" | "allowModelOverride"
> & {
senderIsOwner: boolean; // Required at ingress
allowModelOverride: boolean; // Required at ingress
};
Decision Points
| Decision | What controls it | Why it matters |
|---|---|---|
| Access groups enabled | useAccessGroups boolean parameter | When off, the system falls back to modeWhenAccessGroupsOff instead of requiring explicit group membership |
| Fallback mode | modeWhenAccessGroupsOff — "allow", "deny", or "configured" | Determines whether commands are open, locked, or conditionally gated when access groups are disabled |
| Configured check | authorizers.some(e => e.configured) | If no authorizer is configured at all, the system defaults to open access (no config = no restriction) |
| Allowed check | authorizers.some(e => e.configured && e.allowed) | At least one configured authorizer must explicitly grant access for the command to proceed |
| Control command blocking | allowTextCommands && hasControlCommand && !commandAuthorized | Prevents unauthorized senders from executing control commands even when text commands are allowed |
| Plugin requireAuth | requireAuth boolean on plugin command definition | Individual plugins can require authorization regardless of global access group settings |
| Owner bypass | senderIsOwner in AgentCommandIngressOpts | Bot owners typically bypass permission checks for administrative commands |
How It Works
Permission gating operates in three phases: command gating resolution in command-gating.ts, ingress authorization via AgentCommandIngressOpts, and allowlist policy evaluation in the doctor subsystem. The three phases are intentionally decoupled so each can be tested and reasoned about independently.
Phase 1 — Command Gating Resolution
The resolveCommandAuthorizedFromAuthorizers function is the core authorization engine. It evaluates an array of CommandAuthorizer entries and returns a single boolean. The function is pure — no side effects, no state mutation.
// src/channels/command-gating.ts — resolveCommandAuthorizedFromAuthorizers
export function resolveCommandAuthorizedFromAuthorizers(params: {
useAccessGroups: boolean;
authorizers: CommandAuthorizer[];
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): boolean {
const { useAccessGroups, authorizers } = params;
const mode = params.modeWhenAccessGroupsOff ?? "allow";
if (!useAccessGroups) {
// Access groups are disabled — apply fallback mode
if (mode === "allow") return true;
if (mode === "deny") return false;
// mode === "configured": evaluate authorizers normally
const anyConfigured = authorizers.some((entry) => entry.configured);
if (!anyConfigured) return true; // No config = open access
return authorizers.some((entry) => entry.configured && entry.allowed);
}
// Access groups enabled — require at least one configured + allowed
return authorizers.some((entry) => entry.configured && entry.allowed);
}
modeWhenAccessGroupsOff is "configured" and no authorizers are configured at all, the function returns true. This "open by default" behavior prevents accidental lockouts when access groups are turned off before any authorizers have been set up.
Phase 2 — Control Command Gate
The resolveControlCommandGate function wraps the base authorizer resolution and adds control-command-specific blocking logic. It returns both the raw authorization result and a shouldBlock flag that callers use to suppress execution.
// src/channels/command-gating.ts — resolveControlCommandGate
export function resolveControlCommandGate(params: {
useAccessGroups: boolean;
authorizers: CommandAuthorizer[];
allowTextCommands: boolean;
hasControlCommand: boolean;
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): { commandAuthorized: boolean; shouldBlock: boolean } {
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: params.useAccessGroups,
authorizers: params.authorizers,
modeWhenAccessGroupsOff: params.modeWhenAccessGroupsOff,
});
const shouldBlock =
params.allowTextCommands &&
params.hasControlCommand &&
!commandAuthorized;
return { commandAuthorized, shouldBlock };
}
Phase 3 — Allowlist Policy Evaluation
The doctor subsystem provides a secondary authorization layer through allowlist policies. These policies define which senders are allowed to invoke commands from specific contexts — top-level only, nested only, or both.
// src/commands/doctor/shared/allow-from-mode.ts
export type AllowFromMode =
| "topOnly" // Only top-level senders (direct messages)
| "topOrNested" // Both top-level and nested (forwarded) senders
| "nestedOnly"; // Only nested senders (forwarded messages)
// src/commands/doctor/shared/allowlist.ts
export function hasAllowFromEntries(
list?: DoctorAllowFromList,
): boolean {
// Returns true if the allowlist has at least one entry configured
// Used to determine whether allowlist evaluation should proceed
}
maybeRepairAllowlistPolicyAllowFrom function in allowlist-policy-repair.ts automatically corrects malformed allowlist configurations at startup. If an allowlist policy references a mode that no longer exists, the repair function normalizes it to the closest valid mode. This prevents stale configurations from causing blanket denials.
Authorization Flow
The sequence diagram below shows how an authorized command and a blocked command proceed through the permission gating pipeline. The critical divergence point is the resolveControlCommandGate return value.
sequenceDiagram
participant Sender
participant Router as Command Router
participant Gating as command-gating.ts
participant Allowlist as allowlist.ts
participant Handler as Command Handler
Note over Sender,Handler: Scenario A — Authorized sender
Sender->>Router: /model set gpt-4
Router->>Gating: resolveControlCommandGate(authorizers, ...)
Gating->>Gating: resolveCommandAuthorizedFromAuthorizers()
Gating-->>Router: { commandAuthorized: true, shouldBlock: false }
Router->>Allowlist: hasAllowFromEntries(policy)
Allowlist-->>Router: true (sender in allowlist)
Router->>Handler: dispatch "model set gpt-4"
Handler-->>Sender: "Default model updated to gpt-4"
Note over Sender,Handler: Scenario B — Blocked sender
Sender->>Router: /reset agent
Router->>Gating: resolveControlCommandGate(authorizers, ...)
Gating->>Gating: resolveCommandAuthorizedFromAuthorizers()
Gating-->>Router: { commandAuthorized: false, shouldBlock: true }
Router-->>Sender: "Permission denied: not authorized"
In Scenario A, the sender passes both the command gating check and the allowlist check, so the command handler executes normally. In Scenario B, the sender fails the command gating check and the router immediately returns a denial without ever reaching the allowlist or the handler.
Code Walkthrough
Let us trace the path of a /model set gpt-4 command from a non-owner sender when access groups are enabled and one authorizer grants access.
Authorizer entries are collected
The channel adapter collects all CommandAuthorizer entries for the sender. Each entry represents one authorization source — for example, a role-based check, a channel-level permission, or a user-level override. Each entry has configured: true/false and allowed: true/false.
const authorizers: CommandAuthorizer[] = [
{ configured: true, allowed: true }, // Role: admin
{ configured: true, allowed: false }, // Channel: restricted
{ configured: false, allowed: false }, // User override: not set
];
resolveCommandAuthorizedFromAuthorizers evaluates
With useAccessGroups: true, the function skips all fallback logic and goes straight to authorizers.some(e => e.configured && e.allowed). The first entry matches (configured: true, allowed: true), so it returns true.
resolveControlCommandGate wraps the result
With commandAuthorized: true, the shouldBlock computation evaluates to false regardless of allowTextCommands and hasControlCommand values, because the !commandAuthorized term is false.
Allowlist policy is checked
The router calls hasAllowFromEntries with the sender's allowlist. If the sender is in the list (or no allowlist is configured), the command proceeds. If blocked here, a second-layer denial is returned.
Command handler executes
Having passed all gates, the model set gpt-4 command reaches the handler with full AgentCommandIngressOpts — including senderIsOwner: false and allowModelOverride: true (derived from the authorization result).
Edge Cases
| Scenario | Configuration | Result | Rationale |
|---|---|---|---|
| No authorizers configured, groups off | useAccessGroups: false, authorizers: [], mode: "configured" | true (allowed) | anyConfigured is false → open access to prevent accidental lockout |
| All authorizers deny, groups on | useAccessGroups: true, all entries allowed: false | false (denied) | .some(e => e.configured && e.allowed) finds no match |
| Groups off, mode "allow" | useAccessGroups: false, mode: "allow" | true (allowed) | Allow mode bypasses all authorizer evaluation |
| Groups off, mode "deny" | useAccessGroups: false, mode: "deny" | false (denied) | Deny mode rejects all commands regardless of authorizer state |
| Control command, authorized sender | allowTextCommands: true, hasControlCommand: true, authorized | shouldBlock: false | Authorization passes, so !commandAuthorized is false |
| Control command, unauthorized sender | allowTextCommands: true, hasControlCommand: true, not authorized | shouldBlock: true | All three conditions are true: text allowed, is control, not authorized |
| Text commands disabled | allowTextCommands: false, hasControlCommand: true, not authorized | shouldBlock: false | allowTextCommands is false, so the blocking condition short-circuits |
| Plugin with requireAuth | Plugin defines requireAuth: true, sender not authorized | Denied at plugin level | Plugin-level auth is checked independently of command gating |
| Empty allowlist | No entries in DoctorAllowFromList | Depends on scan policy | empty-allowlist-scan.ts determines whether an empty list means open or closed |
| Malformed allowlist policy | Invalid AllowFromMode string | Auto-repaired at startup | maybeRepairAllowlistPolicyAllowFrom normalizes to a valid mode |
Configuration
| Setting | Location | Default | Description |
|---|---|---|---|
useAccessGroups | Channel configuration | false | Master switch for access group enforcement. When off, modeWhenAccessGroupsOff determines fallback. |
modeWhenAccessGroupsOff | Parameter to resolveCommandAuthorizedFromAuthorizers | "allow" | Fallback: "allow" = open, "deny" = locked, "configured" = conditional. |
allowTextCommands | Channel configuration | true | Whether text-based control commands are enabled. If false, control commands are not blocked by gating. |
requireAuth | Plugin command definition (src/plugins/types.ts) | false | Whether a plugin command requires sender authorization. |
AllowFromMode | src/commands/doctor/shared/allow-from-mode.ts | "topOnly" | Controls which message nesting levels are checked against the allowlist. |
senderIsOwner | AgentCommandIngressOpts | Resolved at ingress | Boolean indicating whether the sender is the bot owner (typically bypasses gating). |
Plugin-Level Authorization
In addition to the channel-level command gating, individual plugins can declare their own auth requirement via the requireAuth flag in the plugin type definition:
// src/plugins/types.ts — Plugin requireAuth flag
export interface PluginCommandDefinition {
name: string;
description: string;
acceptsArgs: boolean;
requireAuth?: boolean; // Whether only authorized senders can use this command
handler: (args?: string) => Promise<string>;
}
requireAuth: true will independently verify the sender's authorization. This ensures sensitive plugin commands (like deployment triggers or data exports) have their own security boundary regardless of channel configuration.
Related Topics
The parsing layer that runs before permission gating — extracts action and args from raw slash commands.
Where authorized commands are dispatched to their registered handlers after passing permission checks.
How native names and provider-specific aliases resolve during lookup — happens in parallel with gating.
Source Files
Key files involved in permission gating
src/
├── channels/
│ └── command-gating.ts ← CommandAuthorizer type, resolveCommandAuthorizedFromAuthorizers(),
│ resolveControlCommandGate()
├── agents/
│ └── command/
│ └── types.ts ← AgentCommandOpts, AgentCommandIngressOpts
├── commands/
│ └── doctor/
│ └── shared/
│ ├── allowlist.ts ← hasAllowFromEntries()
│ ├── allow-from-mode.ts ← AllowFromMode type
│ ├── allowlist-policy-repair.ts ← maybeRepairAllowlistPolicyAllowFrom()
│ ├── empty-allowlist-policy.ts ← Default empty allowlist policy
│ ├── empty-allowlist-scan.ts ← Empty allowlist scan behavior
│ └── open-policy-allowfrom.ts ← Open policy allow-from defaults
└── plugins/
└── types.ts ← requireAuth flag on plugin commands