Permission Gating

Focus Deep-Dive

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

Primary role

Determine whether an incoming command from a specific sender is authorized to execute, based on access groups, authorizer entries, and allowlist policies.

Main inputs

CommandAuthorizer[] entries, useAccessGroups flag, allowTextCommands setting, senderIsOwner boolean, and optional allowlist configuration.

Key decisions

Are access groups enabled? Does any authorizer grant access? Should control commands be blocked? Does the allowlist policy permit the sender?

Outputs / side effects

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
TypePurposeKey fields
CommandAuthorizerRepresents a single authorization check resultconfigured — whether the authorizer has been set up; allowed — whether it grants access
CommandGatingModeWhenAccessGroupsOffControls fallback when access groups are disabledUnion of "allow", "deny", "configured"
AgentCommandOptsOptions passed to agent command handlerssenderIsOwner, allowModelOverride among others
AgentCommandIngressOptsNarrowed version of AgentCommandOpts with required auth fieldsSame 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

DecisionWhat controls itWhy it matters
Access groups enableduseAccessGroups boolean parameterWhen off, the system falls back to modeWhenAccessGroupsOff instead of requiring explicit group membership
Fallback modemodeWhenAccessGroupsOff — "allow", "deny", or "configured"Determines whether commands are open, locked, or conditionally gated when access groups are disabled
Configured checkauthorizers.some(e => e.configured)If no authorizer is configured at all, the system defaults to open access (no config = no restriction)
Allowed checkauthorizers.some(e => e.configured && e.allowed)At least one configured authorizer must explicitly grant access for the command to proceed
Control command blockingallowTextCommands && hasControlCommand && !commandAuthorizedPrevents unauthorized senders from executing control commands even when text commands are allowed
Plugin requireAuthrequireAuth boolean on plugin command definitionIndividual plugins can require authorization regardless of global access group settings
Owner bypasssenderIsOwner in AgentCommandIngressOptsBot 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);
}
Key insight: When 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
}
Repair logic: The 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

ScenarioConfigurationResultRationale
No authorizers configured, groups offuseAccessGroups: false, authorizers: [], mode: "configured"true (allowed)anyConfigured is false → open access to prevent accidental lockout
All authorizers deny, groups onuseAccessGroups: true, all entries allowed: falsefalse (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 senderallowTextCommands: true, hasControlCommand: true, authorizedshouldBlock: falseAuthorization passes, so !commandAuthorized is false
Control command, unauthorized senderallowTextCommands: true, hasControlCommand: true, not authorizedshouldBlock: trueAll three conditions are true: text allowed, is control, not authorized
Text commands disabledallowTextCommands: false, hasControlCommand: true, not authorizedshouldBlock: falseallowTextCommands is false, so the blocking condition short-circuits
Plugin with requireAuthPlugin defines requireAuth: true, sender not authorizedDenied at plugin levelPlugin-level auth is checked independently of command gating
Empty allowlistNo entries in DoctorAllowFromListDepends on scan policyempty-allowlist-scan.ts determines whether an empty list means open or closed
Malformed allowlist policyInvalid AllowFromMode stringAuto-repaired at startupmaybeRepairAllowlistPolicyAllowFrom normalizes to a valid mode

Configuration

SettingLocationDefaultDescription
useAccessGroupsChannel configurationfalseMaster switch for access group enforcement. When off, modeWhenAccessGroupsOff determines fallback.
modeWhenAccessGroupsOffParameter to resolveCommandAuthorizedFromAuthorizers"allow"Fallback: "allow" = open, "deny" = locked, "configured" = conditional.
allowTextCommandsChannel configurationtrueWhether text-based control commands are enabled. If false, control commands are not blocked by gating.
requireAuthPlugin command definition (src/plugins/types.ts)falseWhether a plugin command requires sender authorization.
AllowFromModesrc/commands/doctor/shared/allow-from-mode.ts"topOnly"Controls which message nesting levels are checked against the allowlist.
senderIsOwnerAgentCommandIngressOptsResolved at ingressBoolean 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>;
}
Defense in depth: Even if a sender passes the channel-level command gating, a plugin with 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.
Command Parsing

The parsing layer that runs before permission gating — extracts action and args from raw slash commands.

Command Registry

Where authorized commands are dispatched to their registered handlers after passing permission checks.

Command Aliases

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