OpenClaw Tool Permission System

01Architecture Overview

OpenClaw's tool permission system is a multi-layer defense-in-depth stack. Each layer independently decides whether a tool invocation is allowed — and all layers must agree before execution proceeds.

The four layers compose in order of increasing specificity: the Tool Policy layer controls which tool names an agent can call at all; the Exec Security layer governs how shell commands execute; the Exec Approvals layer adds human-in-the-loop gating; and the Elevated Mode layer provides a controlled escape hatch for sandboxed agents that need host access.

Layer What It Controls Key Source Applies To
1. Tool Policy Which tool names are visible/callable by an agent src/agents/tool-policy.ts All agents, all tools
2. Exec Security How shell commands are classified and gated src/infra/exec-approvals-allowlist.ts Exec tool only
3. Exec Approvals Human approval lifecycle for each exec call src/gateway/exec-approval-manager.ts Gateway / Node hosts
4. Elevated Mode Sandbox-to-host breakout with authorization src/infra/elevated-exec.ts Sandboxed agents

02Decision Flowchart

When an agent invokes a tool, the request traverses this decision tree top-to-bottom. The first DENY encountered ends execution immediately.

Agent requests tool call
  │
  ▼
┌─────────────────────────────────────────────┐
│  LAYER 1 — Tool Policy                      │
│  Is this tool name in the agent's           │
│  allowed set? (profile + allow/deny lists)  │
└───────────┬─────────────────────────────────┘
            │
    NO ─────┴──→  DENY  (tool not available to agent)
            │
           YES
            │
            ▼
┌─────────────────────────────────────────────┐
│  Is this the "exec" tool?                   │
└───────────┬─────────────────────────────────┘
            │
    NO ──────┴──→  Apply non-exec tool policy (read / write /
            │     browser / web_fetch) and ALLOW or DENY
           YES
            │
            ▼
┌─────────────────────────────────────────────┐
│  LAYER 2 — Exec Security Mode               │
│  What is the agent's exec security mode?    │
└───────────┬─────────────────────────────────┘
            │
         "deny" ──────────────────────────────→  DENY  (all exec blocked)
            │
         "full" ──────────────────────────────→  skip to LAYER 3
            │
        "allowlist"
            │
            ▼
┌─────────────────────────────────────────────┐
│  Command Analysis                           │
│  1. Unwrap shell wrappers (bash -c, env…)   │
│  2. Parse chain operators (&& || ; | )      │
│  3. Check each segment individually         │
└───────────┬─────────────────────────────────┘
            │
  Redirection / subshell found ────────────→  DENY  (unconditional)
            │
  Any segment not satisfying allowlist ────→  DENY  (unless ask triggers)
            │
  All segments satisfy allowlist or safe-bin
            │
            ▼
┌─────────────────────────────────────────────┐
│  LAYER 3 — Exec Approvals (ask mode)        │
│  ask = off ?                                │
└───────────┬─────────────────────────────────┘
            │
          YES ─────────────────────────────→  ALLOW  (no approval needed)
            │
           NO (on-miss or always)
            │
            ▼
┌─────────────────────────────────────────────┐
│  ask = "always" OR command not in history?  │
│  → Create approval request, broadcast UI    │
│  → Wait for operator (timeout → deny)       │
└───────────┬─────────────────────────────────┘
            │
       DENIED ────────────────────────────→  DENY  (operator rejected)
            │
         APPROVED
            │
            ▼
┌─────────────────────────────────────────────┐
│  LAYER 4 — Elevated Mode (sandboxed only)   │
│  Is agent in sandbox? Elevated requested?   │
└───────────┬─────────────────────────────────┘
            │
    Sandbox, no elevated ─────────────────→  Execute in sandbox
            │
    Elevated allowed for this sender ─────→  Execute on HOST
            │
            ▼
         ALLOW — command executes

03Profiles & Groups

Every agent is assigned a tool profile that defines its baseline tool availability. Profiles are defined in src/agents/tool-catalog.ts and expanded to concrete tool sets before policy allow/deny lists are applied.

Built-in Profiles

Profile Included Tool Groups Typical Use Case
full All groups — fs, runtime, web, memory, sessions, ui, messaging, automation, nodes, agents, media Default for main agent with full capabilities
coding fs, runtime, memory, sessions (limited), media, web Code-focused agents, Claude Code integration
messaging messaging, sessions (limited) Relay agents that only forward messages
minimal session_status only Read-only status agents, lightweight subagents

Tool Groups

Profiles are built from named groups. Groups can also be used directly in allow/deny lists with the group: prefix.

Group Tools Notes
group:fs read, write, edit, apply_patch File system access
group:runtime exec, process Command execution
group:web web_search, web_fetch Internet access
group:memory memory_search, memory_get Long-term memory
group:sessions sessions_list, sessions_history, sessions_send, sessions_spawn, sessions_yield, subagents, session_status Multi-agent orchestration
group:ui browser, canvas GUI interaction
group:messaging message Send messages to channels
group:automation cron, gateway Scheduled and gateway control
group:nodes nodes Remote node management
group:agents agents_list Agent enumeration (owner-only)
group:media image, image_generate, tts Image generation and text-to-speech
group:plugins All currently loaded plugin tools Dynamic — expands to all plugin tools at runtime
<pluginId> Tools belonging to a specific plugin Per-plugin group; use bare plugin ID (no group: prefix)

Tools not included in any built-in profile: browser, canvas, gateway, nodes, agents_list, and tts are absent from all profiles including full. They must be explicitly added via tools.allow or tools.alsoAllow.

Coding Profile — Exact Tool List

The coding profile includes exactly these tools (source: src/agents/tool-catalog.ts):

read, write, edit, apply_patch,         // filesystem
exec, process,                           // runtime
web_search, web_fetch,                   // web
memory_search, memory_get,               // memory
sessions_list, sessions_history,         // sessions (limited)
sessions_send, sessions_spawn,
sessions_yield, subagents,
session_status, cron,                    // scheduling
image, image_generate                    // media (no tts)

Note: browser, canvas, gateway, nodes, agents_list, tts, and channel tools are excluded even from coding.

Tool Name Normalization

Before any policy matching, tool names are normalized via normalizeToolName() and normalizeToolList() (src/agents/tool-policy-shared.ts). Two aliases are recognized:

"bash"        → "exec"          // legacy alias
"apply-patch" → "apply_patch"   // hyphen → underscore normalization

This means config entries like allow: ["bash"] correctly enable the exec tool, and deny: ["apply-patch"] correctly blocks apply_patch.

04Allow / Deny Lists

After a profile establishes the baseline set, allow and deny lists refine it. Deny always wins — a tool in both lists is blocked. Lists accept individual tool names, group: prefixes, and glob patterns via src/agents/tool-policy-match.ts.

Policy Scopes (in application order)

Scope Config Key Description
Global tools.allow / tools.deny Applies to all agents across all providers
Provider-specific providers[n].tools.allow / .deny Applies to all agents that use provider n
Agent-specific agents[n].tools.allow / .deny Applies to a named agent only
Sandbox override agents[n].sandbox.alsoAllow Extra allow list for sandboxed copies of the agent (src/agents/sandbox-tool-policy.ts)

Owner-Only Tools

Four tools are marked owner-only: whatsapp_login, cron, gateway, nodes. They receive double protection: they are silently removed from the tool list for any non-owner agent, and their execute handler is replaced with an exception-throwing wrapper so even a direct internal call is rejected. No profile or allow list can grant these tools to a non-owner agent.

apply_patch + exec Special Rule

When exec is present in the effective allow list, apply_patch is implicitly allowed even if not listed explicitly. This is step 4 of the match logic in src/agents/tool-policy-match.ts:

allow: ["exec"]
  → exec: allowed (explicit match)
  → apply_patch: allowed (implicit, because exec is in allow list)
  → read: denied (not in allow list and apply_patch rule does not extend further)

Plugin Allowlist Safety — stripPluginOnlyAllowlist

A common mistake is setting tools.allow to a list of plugin tools. If the allow list contains only plugin tools (no core tools and no * wildcard), the system detects this via stripPluginOnlyAllowlist() and strips the allow list entirely — preventing the operator from accidentally blocking all core tools.

The function returns an AllowlistResolution object with three fields: policy (resulting policy after stripping), unknownAllowlist (entries that were not recognized core tools), and strippedAllowlist (a boolean flag indicating whether the allow list was stripped).

# DANGEROUS — accidentally blocks read, write, exec, etc.:
tools:
  allow: ["my-plugin-tool", "other-plugin-tool"]

# CORRECT — use alsoAllow to extend without replacing the baseline:
tools:
  alsoAllow: ["my-plugin-tool", "other-plugin-tool"]

tools.alsoAllow appends to the effective allowed set without replacing it. Use alsoAllow for plugins and allow only when you intentionally want to restrict to an explicit list.

Pattern Matching

Tool name matching is case-insensitive glob matching. The group: prefix is expanded to the full tool list before matching. Examples:

# Allow only filesystem and web tools
allow: ["group:fs", "group:web"]

# Deny all write operations
deny: ["write", "edit", "apply_patch"]

# Deny everything in the runtime group
deny: ["group:runtime"]

# Add a plugin tool without restricting core tools
alsoAllow: ["my-plugin-tool"]

05Policy Pipeline

src/agents/tool-policy-pipeline.ts wires the scopes together into a deterministic resolution sequence. The pipeline runs once per agent initialization and produces a frozen allowed-tool set that is injected into the agent's tool schema.

The pipeline runs 7 ordered steps. Each step is identified by a label that corresponds to a config key. Steps run in this exact order (src/agents/tool-policy-pipeline.ts):

Step 1 — tools.profile
  Config key: tools.profile
  Expand profile name → concrete tool list
  Apply owner-only filter (strip privileged tools for non-owner agents)
  Apply implicit alsoAllow:
    tools.exec.* section present → implicit "exec", "process" added
    tools.fs.* section present   → implicit "read", "write", "edit" added

Step 2 — tools.byProvider.profile
  Config key: tools.byProvider.<providerId>.profile
  Provider-level profile override for agents under this provider

Step 3 — tools.allow
  Config key: tools.allow
  Global allow list: if set, keep only matching tools
  Empty allow list = no filtering (allow all from profile)

Step 4 — tools.byProvider.allow
  Config key: tools.byProvider.<providerId>.allow / .deny
  Provider-level allow/deny applied after global

Step 5 — agents.<id>.tools.allow
  Config key: agents.<id>.tools.allow / .deny
  Per-agent allow/deny applied after provider

Step 6 — agents.<id>.tools.byProvider.allow
  Config key: agents.<id>.tools.byProvider.<providerId>.allow / .deny
  Per-agent, per-provider fine-grained override

Step 7 — group tools.allow
  Group-level resolution for plugin and channel tool groups
  Calls: resolveGroupToolPolicy() → resolveChannelGroupToolsPolicy()
  Supports plugin-custom groups.resolveToolPolicy hook

Result: frozen tool set → injected into LLM tool schema

AND semantics: policies from multiple layers are all applied; a tool must pass every layer's filter. The frozen set is the only set the LLM ever sees — the schema simply does not include blocked tools.

Warning deduplication: repeated pipeline warnings are suppressed after the first occurrence using an LRU-style cache with a 256-entry limit (rememberToolPolicyWarning() in src/agents/tool-policy-pipeline.ts).

05bSub-Agent Tool Policy

Subagents (spawned by sessions_spawn or subagents) receive a more restricted tool set than the orchestrator. Two deny lists are applied on top of the normal pipeline (src/agents/pi-tools.policy.tsresolveSubagentToolPolicy(cfg, depth)):

DENY_ALWAYS — All Subagents

These 8 tools are always denied for every subagent regardless of depth or config:

gateway          — cannot control the gateway
agents_list      — cannot enumerate other agents
whatsapp_login   — owner-only auth action
session_status   — session introspection
cron             — cannot create scheduled tasks
memory_search    — long-term memory read
memory_get       — long-term memory fetch
sessions_send    — cannot initiate messages to sessions

An operator can remove an entry from this deny list by explicitly listing the tool in the agent's tools.allow config — but this should be done only when the specific capability is intentional and reviewed.

DENY_LEAF — Leaf Agents Only

Leaf agents (depth ≥ maxSpawnDepth) additionally lose these 4 tools, preventing recursive spawning:

subagents         — cannot spawn further subagents
sessions_list     — cannot enumerate sessions
sessions_history  — cannot read session history
sessions_spawn    — cannot create new sessions

Depth-Based Classification

depth = 0          → orchestrator (main agent) — no subagent restrictions
depth < maxDepth   → orchestrator-tier subagent — DENY_ALWAYS only
depth ≥ maxDepth  → leaf agent — DENY_ALWAYS + DENY_LEAF

resolveSubagentToolPolicy(cfg, depth)
resolveSubagentToolPolicyForSession(cfg, sessionKey)  // uses stored session role

Sandbox Tool Policy Constants

Sandboxed agents use a separate default allow/deny set defined in sandbox/constants.ts. These defaults apply before any config overrides:

DEFAULT_TOOL_ALLOW (line 13) — tools allowed in sandbox by default:
  exec, process, read, write, edit, apply_patch,
  image, sessions_list, sessions_history,
  sessions_send, sessions_spawn, sessions_yield,
  subagents, session_status

DEFAULT_TOOL_DENY (line 31) — tools always denied in sandbox:
  browser, canvas, nodes, cron, gateway
  + all channel IDs (discord, telegram, slack, …)

Config can override defaults via agents[n].tools.sandbox.tools.allow/deny or agents[n].sandbox.alsoAllow. The source of each decision is tracked as SandboxToolPolicySource: "agent", "global", or "default", along with the config path string for diagnostics.

06Exec Security Modes

Even when the exec tool is allowed by policy, a second gate controls how it executes. The mode is set per-agent in agents[n].exec.security (or globally in tools.exec.security).

Mode Behavior Default For
deny All exec calls are rejected immediately. The exec tool may still appear in the schema (so the agent knows it exists) but every invocation returns a permission denied error. Sandbox environments
allowlist Commands are evaluated against a per-agent allowlist of glob patterns. Commands matching the list may proceed to the approval gate; unmatched commands are denied (or trigger ask on-miss). Gateway / Node hosts
full No allowlist checking. All syntactically valid commands pass through to the approval gate. Use with extreme caution — the agent can run arbitrary code. Explicit operator opt-in only

Mode Interaction with Ask

In allowlist mode, the ask setting determines what happens when a command is not in the allowlist:

security = "allowlist", ask = "off"
  → Commands NOT in allowlist: DENY immediately
  → Commands IN allowlist:     ALLOW (no approval prompt)

security = "allowlist", ask = "on-miss"
  → Commands NOT in allowlist: ASK operator → approve adds to allowlist
  → Commands IN allowlist:     ALLOW (no approval prompt)

security = "allowlist", ask = "always"
  → All commands: ASK operator every time
  → Allowlist still checked for auto-allow; patterns in list skip prompt

security = "full", ask = "on-miss"
  → Allowlist not checked; ASK for everything not previously seen

security = "full", ask = "always"
  → ASK for every single exec call, no exceptions

07Safe-Bin Policy

Safe bins are a curated set of Unix filter commands that are auto-allowed in allowlist mode without requiring an explicit pattern in the allowlist. They are only allowed when invoked in a stdin-only fashion — no file operands, no output redirection, no dangerous flags.

Implementation lives in src/infra/exec-safe-bin-policy-profiles.ts and src/infra/exec-safe-bin-policy.ts.

Default Safe Bins

cut    — field/byte splitting (stdin only, flags: -b -c -f -d)
uniq   — de-duplication (stdin only)
head   — first N lines (stdin only, flags: -n -c)
tail   — last N lines (stdin only, flags: -n -c)
tr     — character translation (stdin only, 1–2 positional args)
wc     — line/word/byte count (stdin only)

Extended Safe-Bin Profiles

Additional tools can be promoted to safe-bin status via profiles — which strictly constrain allowed flags to prevent file access or code execution:

Tool Allowed Flags Denied Flags Posit. Args
jq --arg, --argjson, --argstr --rawfile, --argfile, -f, -L max 1 (filter expr)
grep -e, --regexp, -i, -v, -c, -n, -m, -A, -B, -C, --include, --exclude --file, --exclude-from, --dereference-recursive, --directories, -f, -d, -r, -R 0 (stdin only)
sort -k, -t, -n, -r, -u --output, -o, --compress-program, --random-source 0 (stdin only)

Trusted Directories

The safe-bin check also validates that the resolved executable path comes from a trusted directory. Default trusted dirs are /bin and /usr/bin. This prevents a malicious script placing a fake cut binary in /tmp and having it auto-allowed.

# Config example
tools:
  exec:
    safeBins: ["cut", "uniq", "head", "tail", "tr", "wc"]
    safeBinTrustedDirs: ["/bin", "/usr/bin", "/opt/homebrew/bin"]
    safeBinProfiles:
      jq:
        allowedValueFlags: ["--arg", "--argjson"]
        deniedFlags: ["-f", "--argfile", "-L"]
        maxPositional: 1

Specific Rejection Rules

Each safe-bin profile implements fine-grained argument validation beyond just allowed/denied flags. Notable rejection cases:

grep:
  grep pattern file.txt     → REJECTED (positional pattern arg without -e)
  grep -e SECRET .env       → REJECTED (file positional arg; stdin only)
  grep -n TODO src/          → REJECTED (path positional arg)
  grep -e TODO               → ALLOWED (stdin, explicit -e)

jq:
  jq 'env'                   → REJECTED (env access via jq filter)
  jq '.foo | env.BAR'        → REJECTED (env access)
  jq 'env.FOO'               → REJECTED (env access)
  jq '.field'                → ALLOWED

sort:
  sort --compress-program=sh → REJECTED (code execution via compression)
  sort --files0-from=f       → REJECTED (file input)
  sort -k1,1                 → ALLOWED

wc:
  wc --files0-from=f         → REJECTED (file input)
  wc -l                      → ALLOWED

Token safety rules (apply to all safe bins):
  command -- --unknown-flag  → REJECTED (unknown long options fail closed)
  command -- /path/to/file   → REJECTED (path/glob after -- terminator rejected)
  command - (stdin)          → ALLOWED (stdin marker allowed even after --)

Skill Auto-Allow

When autoAllowSkills: true is set, commands that match a registered skill's binary are automatically allowed. Skills must explicitly register their binaries — this prevents skills from inadvertently granting broad permissions. See src/infra/exec-approvals-allowlist.ts.

08Command Analysis

Before allowlist matching, every exec command goes through a multi-step analysis pipeline (src/infra/exec-approvals-analysis.ts, src/infra/exec-command-resolution.ts, src/infra/shell-wrapper-resolution.ts). The goal is to determine the actual command being run after all wrappers and aliases are removed.

Step 1 — Shell Wrapper Unwrapping

If the command starts with a shell (bash, sh, zsh, fish, ksh, dash) or a multiplexer (busybox, toybox), the wrapper is peeled off to reveal the inner command or script:

bash -c "grep -n TODO src/"
  → shell wrapper detected (bash with -c flag)
  → inner command: "grep -n TODO src/"

env -i PATH=/usr/bin bash -c "ls"
  → env wrapper unwrapped (env passes through)
  → shell wrapper detected
  → inner command: "ls"

busybox grep pattern file
  → busybox multiplexer → applet: grep
  → inner command: grep pattern file

Wrapper detection is in src/infra/shell-wrapper-resolution.ts. Inline command flags are defined in src/infra/shell-inline-command.ts (POSIX_INLINE_COMMAND_FLAGS: -lc, -c, --command).

Step 2 — Chain Operator Parsing

Commands joined by shell operators are split into segments. Each segment is evaluated independently. If any segment fails allowlist matching, the entire command is rejected.

Detected operators and their treatment:
  |    pipe         → split into segments, all must satisfy
  &&   and-then      → split into segments, all must satisfy
  ||   or-then       → split into segments, all must satisfy
  ;    sequence      → split into segments, all must satisfy
  &    background    → split into segments, all must satisfy

Unconditional rejection (no allowlist can override):
  >    stdout redirect
  >>   append redirect
  <    stdin redirect (overrides safe-bin stdin-only rule)
  2>   stderr redirect
  $()  command substitution
  ``   backtick substitution

Step 3 — Package Runner Unwrapping

Common package runners are also unwrapped to reveal the actual script:

npm exec -- tsc --noEmit    → tsc --noEmit
npx prettier --write .      → prettier --write .
pnpm exec vitest            → vitest

Step 4 — Inline Eval Detection

Commands that embed code strings (e.g. python -c "...", node -e "...", perl -e "...") are treated as high-risk. When strictInlineEval: true is set (default: false), these always require an explicit approval even if the binary (python, node) is in the allowlist.

Allow-Always Pattern Derivation

When a command is approved and ask = "on-miss", the system derives a pattern to persist in the allowlist via resolveAllowAlwaysPatterns() (src/infra/exec-approvals-allowlist.ts). The derivation rules depend on how the command was invoked:

Direct executable:
  /usr/bin/rg -n TODO src/
  → pattern: full executable path "/usr/bin/rg"

Shell wrapper (bash -lc 'whoami'):
  → unwrap inner command → persist inner executable path
  bash -lc 'whoami'  → pattern: resolved path of "whoami"

Shell wrapper with script path (bash scripts/save.sh):
  → persist absolute path of the script file
  bash scripts/save.sh  → pattern: "/abs/path/scripts/save.sh"

REJECTION — inline not treated as script path:
  bash -lc 'scripts/save.sh'  → REJECTED (inline string, not a file path)
  bash -s scripts/save.sh     → REJECTED (stdin mode with file path)

Scheduler wrapper (nice, timeout, sudo):
  nice whoami  → strip wrapper → pattern: resolved path of "whoami"

Busybox applet:
  busybox grep pattern  → applet "grep" → pattern: resolved grep path

Positional argv carrier:
  sh -lc '$0 "$1"' touch /path/to/file
  → $0 is the external carrier; pattern: resolved path of "touch"
  REJECTED if: $0 is single-quote wrapped, exec and $0 are separated
               by newline, or inline includes extra shell operations
               (e.g. "echo blocked; $0")

Chain deduplication:
  whoami && ls && whoami  → [resolved(whoami), resolved(ls)]  (deduped)

09Approval Lifecycle

The approval system provides human-in-the-loop oversight for exec calls. It is implemented across the gateway (src/gateway/exec-approval-manager.ts), the node service, and multiple UI surfaces.

Complete Lifecycle

1. Agent requests exec
   └─ OpenClaw gateway receives system.run tool call

2. Pre-flight checks
   ├─ Tool policy: is exec allowed for this agent?
   ├─ Security mode: deny / allowlist / full?
   └─ Allowlist match: does command pass without ask?
      Note: security="full" never triggers approval even with ask="on-miss"

3. Approval request created (if ask triggers)
   ├─ crypto.randomUUID() assigned as approval ID
   ├─ 12-field request payload captured:
   │   commandPreview, commandArgv, envKeys,
   │   systemRunBinding, systemRunPlan, host,
   │   turnSourceChannel, agentId, sessionKey, …
   ├─ sanitizeExecApprovalDisplayText() called
   ├─ buildSystemRunApprovalBinding() + resolveSystemRunApprovalRequestContext()
   └─ Record stored in ApprovalManager (register() — idempotent)

   If no approval route available (no UI, no forwarder configured):
   → expire("no-approval-route") called immediately; request never queued

4. Broadcast exec.approval.requested
   ├─ All connected UI clients notified
   ├─ macOS app shows system notification
   └─ Forwarder sends to configured channels
      └─ (Discord / Telegram / Slack messages with approve/deny buttons)
      └─ Channel plugin may suppress local CLI prompt if remote UI handles it
         (shouldSuppressLocalExecApprovalPrompt())

5. Operator decision window (default: 120 seconds)
   ├─ Operator approves via: UI, macOS app, chat /approve command
   ├─ awaitDecision() blocks agent fiber until resolved or timeout
   └─ On 120s timeout: expire() called → askFallback applied (deny or allowlist)

6. Gateway resolves approval (exec.approval.resolve — prefix matching)
   ├─ APPROVED: command proceeds to execution
   │   ├─ Pattern saved to allowlist (for on-miss ask mode)
   │   ├─ consumeAllowOnce() — atomic, prevents replay attacks
   │   └─ Approval binding forwarded to node if needed
   └─ DENIED: system event "Exec denied" sent to session
   After resolve: 15-second grace window, then cleanup removes the record

7. Node host validation (gateway → node flow only)
   ├─ Node receives system.run with approval token
   ├─ Validates approval binding matches request
   │   (argv hash, cwd, script sha256 if applicable)
   └─ Executes or denies based on binding match

8. Execution completes
   └─ System events: "Exec running" → "Exec finished" / error

Approval Manager — Core Methods

create()           — build a new approval record
register()         — store record; idempotent (safe to call twice)
resolve()          — record operator decision; starts 15-sec grace cleanup
expire()           — mark as expired (timeout or no-route); unblocks awaitDecision()
consumeAllowOnce() — atomically consume a one-time allow; prevents replay
awaitDecision()    — suspend caller until decision; no built-in timeout (timeout managed by register())
lookupPendingId()  — find a pending approval by UUID prefix (for short IDs)
getSnapshot()      — return a single ExecApprovalRecord | null for the given recordId

Allowlist Persistence

When a command is approved in on-miss mode, the derived pattern is persisted via addAllowlistEntry() (with deduplication). Each subsequent use of an allowlisted command calls recordAllowlistUse() which updates three fields:

lastUsedAt       — timestamp of most recent use
lastUsedCommand  — exact command string that matched
lastResolvedPath — resolved absolute path of the executable

The file is written with permission 0o600. Socket token is generated with crypto.randomBytes(24).toString("base64url").

10Ask Modes

The ask setting controls when the approval prompt fires. It applies per-agent and interacts with the allowlist and security mode.

Mode Trigger Condition Effect on Allowlist
off Never prompts. Commands not in allowlist are denied silently. Allowlist is the only gate. Approved = in list, else deny.
on-miss Prompts when command is NOT in allowlist. Approval adds pattern to allowlist for future runs. Grows the allowlist on each approval. Good for learning mode.
always Prompts for every exec call, even if already in allowlist. Allowlist still checked for auto-approve shortcuts; operator always sees prompt.

Ask Fallback

askFallback specifies behavior when an approval times out (operator does not respond within the timeout window):

askFallback: "deny"       → timeout → command denied (safe default)
askFallback: "allowlist"  → timeout → re-evaluate against allowlist only
                            (if in list: allow, if not: deny)

Forwarder Configuration

The forwarder sends approval requests to chat channels when the operator is not watching the UI. Configured under approvals.exec:

{
  "approvals": {
    "exec": {
      "enabled": true,
      "mode": "session",     // "session" | "targets" | "both"
      "agentFilter": ["main"],
      "sessionFilter": ["discord"],
      "targets": [
        { "channel": "slack", "to": "U12345678" },
        { "channel": "discord", "to": "123456789" }
      ]
    }
  }
}

mode: "session" forwards to the same session that triggered the exec. mode: "targets" forwards to the explicit targets list. mode: "both" does both.

11Approval Binding

When an approval flows from the gateway to a remote node host, the system must guarantee that the node executes exactly the command the operator approved — not a substituted or modified command. This is enforced via approval binding (src/infra/system-run-approval-binding.ts).

Binding Components

Approval binding captures at approval time:
  ├─ argv[]      — exact command + arguments array
  ├─ cwd         — working directory
  ├─ env{}       — environment variable snapshot
  ├─ agentId     — which agent requested the command
  ├─ sessionKey  — which session this belongs to
  └─ scriptHash  — if command is a script file: sha256(file contents)
                   + scriptPath for path verification

All fields are hashed together into a binding token.

Binding Validation (Node Side)

When the node receives the forwarded approval, it recomputes the binding from the actual request parameters and compares with the token. If any field differs, execution is denied:

Node receives:  system.run { argv, cwd, env, approvalToken }
                  │
                  ▼
              Recompute binding from actual params
                  │
              binding_actual == binding_in_token?
                  │
       YES ───────┴──────→ Execute
       NO                → Deny ("approval binding mismatch")

This prevents TOCTOU attacks where a compromised agent modifies the command between the approval decision and execution. If the binding cannot be computed (e.g. script file is unreadable), execution is denied rather than skipped.

12Forwarder → Resolver → Timeout Flow

This is the complete data flow for an approval that is forwarded to a chat channel (src/gateway/node-invoke-system-run-approval.ts, src/gateway/server-methods/exec-approval.ts):

Agent                  Gateway                 Node                  Operator (chat)
  │                      │                      │                        │
  │  system.run call     │                      │                        │
  │─────────────────────▶│                      │                        │
  │                      │ create approval UUID │                        │
  │                      │ store in ApprovalMgr │                        │
  │                      │──────────────────────────────────────────────▶│
  │                      │         exec.approval.requested broadcast      │
  │                      │                      │                        │
  │  (waiting...)        │                      │       /approve UUID     │
  │                      │◀──────────────────────────────────────────────│
  │                      │ exec.approval.resolve│                        │
  │                      │ decision = "approve" │                        │
  │                      │ record resolved       │                        │
  │                      │                      │                        │
  │                      │ forward to node       │                        │
  │                      │─────────────────────▶│                        │
  │                      │ system.run +          │                        │
  │                      │ approvalToken         │                        │
  │                      │                      │ validate binding        │
  │                      │                      │ execute command         │
  │                      │                      │─────────────────────▶ stdout
  │◀─────────────────────│◀─────────────────────│                        │
  │    tool result        │    exec result        │                        │

TIMEOUT path (operator does not respond):
  Gateway timer fires → askFallback applied
    "deny"      → rejection returned to agent
    "allowlist" → re-check allowlist; allow or deny accordingly

RECONNECT path (exec.approval.waitDecision):
  UI client disconnects and reconnects mid-approval
  → call exec.approval.waitDecision with known approval ID
  → resumes blocking wait without re-creating the approval request

Socket Communication

The approval manager communicates via a Unix domain socket at ~/.openclaw/exec-approvals.sock using JSONL protocol (one JSON object per line). The socket token is stored in exec-approvals.json and used to authenticate all socket messages. Socket connection timeout: 15 seconds (separate from the 120-second approval decision timeout).

Gateway Server Methods

The gateway exposes these JSON-RPC methods for approval management (src/gateway/server-methods/exec-approval.ts):

exec.approval.request      — create approval; twoPhase mode returns
                              { status: "accepted" } immediately then resolves async
exec.approval.waitDecision — wait for decision on existing approval (reconnect)
exec.approval.resolve      — submit operator decision; supports UUID prefix matching

exec.approvals.get         — read exec-approvals.json; redacts socket token
                              (redactExecApprovals() removes token before response)
exec.approvals.set         — write exec-approvals.json; requires baseHash matching
                              current file hash (optimistic concurrency control)
                              calls mergeExecApprovalsSocketDefaults() before write

exec.approvals.node.get    — remote proxy: read node's exec-approvals.json
exec.approvals.node.set    — remote proxy: write node's exec-approvals.json
                              both route via nodeRegistry.invoke()

Optimistic concurrency: exec.approvals.set requires a baseHash matching the current file hash. If another process updated the file since the client last read it, the set call fails and the client must reload before retrying.

13Shell Hardening

For sandboxed exec environments, OpenClaw applies additional hardening beyond the allowlist and approval gates:

Command Binding in Sandbox

Sandbox execution uses src/infra/shell-wrapper-resolution.ts to unwrap the full command chain. The sandbox enforcer then rebuilds the command using only the verified binary paths — shell wrapper arguments are re-assembled from the parsed structure rather than passed verbatim, preventing injection via shell metacharacters in arguments.

Workspace Isolation Modes

Mode Filesystem View Tools Affected
none Agent sees only ~/.openclaw/sandboxes/<id> read, write, edit, exec all scoped to sandbox
ro Agent workspace mounted at /agent read-only write, edit, apply_patch removed from schema
rw Agent workspace mounted at /workspace read-write All fs tools available within workspace boundary

Sandbox Backends

Configured in agents[n].sandbox.backend:

Backend Isolation Method Scope Options
docker Containerized execution, network isolation session, agent, shared
ssh Remote execution on a separate host session, agent
openShell OS-level shell with restricted env session

Sandbox Modes

sandboxing:
  mode: "off"       → no sandboxing; all agents run on host
  mode: "non-main"  → only subagents and spawned agents are sandboxed
  mode: "all"       → all agents including main are sandboxed

Full sandboxing documentation: docs/gateway/sandboxing.md

14Elevated Execution

Elevated execution allows a sandboxed agent to break out to the host for specific commands. It is the controlled escape hatch — not a bypass of all security, but a structured promotion of individual exec calls.

Elevated Modes

Command Behavior Approval Required?
/elevated on Host exec enabled. Commands run on the host machine with full host access. Yes — normal approval lifecycle applies
/elevated ask Like on, but always prompts the operator even if command is in allowlist. Yes — always prompts regardless of allowlist
/elevated full Host exec enabled AND approvals bypassed. Operator trusted this session fully. No — skips approval gate entirely
/elevated off Disable elevated mode; return to sandbox exec. N/A

Command Alias

/elev is a supported alias for /elevated. Both forms accept the same on / ask / full / off arguments.

Inline vs Session Scope

Elevated directives have two scopes that interact via priority order:

Inline directive  — applies to the current message only; does not persist
Session directive — persists for the duration of the session

Priority (highest to lowest):
  1. Inline directive (current message)
  2. Session-level override (/elevated on/off/full during session)
  3. Global default (agents.defaults.elevatedDefault in config)

Sender Authorization

Not every user can activate elevated mode. The /elevated command is only accepted from senders in the elevated authorization list. This is configured with two levels — global (applies to all agents) and per-agent:

# Global elevated authorization
tools:
  elevated:
    enabled: true
    allowFrom:
      - "discord:123456789"       # bare channel:ID
      - "id:987654321"            # prefixed format

# Per-agent elevated authorization (overrides global for this agent)
agents:
  list:
    - id: main
      tools:
        elevated:
          enabled: true
          allowFrom:
            - "name:Alice"          # by display name
            - "username:alice99"    # by username
            - "tag:alice#1234"      # by username#discriminator (Discord)
            - "id:123456789"        # by platform user ID
            - "from:alice@corp"     # by email / address
            - "e164:+15551234567"   # by E.164 phone number

If an unauthorized sender attempts /elevated full, the command is rejected and the attempt is logged as a security event.

Elevated vs Sandbox Interaction

Sandboxed agent + /elevated on   → exec runs on HOST, approval required
Sandboxed agent + /elevated full → exec runs on HOST, no approval
Non-sandboxed agent (host)       → always runs on host; elevated not needed

Key constraint: Tool policy deny STILL applies in /elevated full mode.
If exec is denied by tool policy, /elevated cannot override it.
Elevated only controls sandbox-to-host promotion; it does not bypass Layer 1.

Full elevated documentation: docs/tools/elevated.md

15Non-Exec Tool Policies

Tools other than exec have their own policy controls. These sit at Layer 1 (tool policy) and do not use the approval lifecycle — they are configured once and applied statically.

Read Tool

tools:
  read:
    allow: ["~/Projects/**", "/tmp/**"]   # glob allowlist for paths
    deny: ["~/.ssh/**", "~/.gnupg/**"]    # always denied paths
    maxSizeBytes: 10485760                 # max file size (10 MB default)

Path patterns are matched against the resolved (absolute) path, preventing ../ traversal attacks. Symlinks are resolved before matching.

Write Tool

tools:
  write:
    allow: ["~/Projects/**", "/tmp/**"]
    deny: ["/etc/**", "/usr/**", "~/.ssh/**"]
    # Write is always denied if path is outside allow list when allow list is set

Browser Tool

tools:
  browser:
    enabled: true | false
    allowedOrigins: ["https://example.com", "https://*.github.com"]
    deniedOrigins: ["https://internal-corp.example"]
    # Empty allowedOrigins = all origins allowed (except denied)

Web Fetch Tool

tools:
  web_fetch:
    enabled: true | false
    allowedDomains: ["github.com", "*.npmjs.com"]
    deniedDomains: ["internal.corp", "169.254.0.0/16"]
    maxResponseBytes: 5242880   # 5 MB
    followRedirects: true

SSRF protection: by default, requests to RFC 1918 addresses (10.x, 172.16.x, 192.168.x) and link-local addresses (169.254.x) are blocked unless explicitly added to allowedDomains.

16Configuration Reference

All permission system knobs live under the main OpenClaw config file. Below is a complete annotated reference.

exec-approvals.json (Runtime State)

The approval allowlist is persisted in ~/.openclaw/exec-approvals.json (src/infra/exec-approvals.ts). It stores the allowlist entries that have been approved over time:

{
  "version": 1,
  "socket": {
    "path": "~/.openclaw/exec-approvals.sock",
    "token": "<base64url-token>"
  },
  "defaults": {
    "security": "allowlist",      // deny | allowlist | full
    "ask": "on-miss",             // off | on-miss | always
    "askFallback": "deny",        // deny | allowlist
    "autoAllowSkills": false      // auto-allow registered skill bins
  },
  "agents": {
    "main": {
      "security": "allowlist",
      "ask": "on-miss",
      "allowlist": [
        {
          "id": "uuid-v4",
          "pattern": "~/Projects/**/bin/rg",
          "lastUsedAt": 1737150000000,
          "lastUsedCommand": "rg -n TODO src/",
          "lastResolvedPath": "/home/user/Projects/.../bin/rg"
        }
      ]
    }
  }
}

Main Config — Exec Settings

tools:
  exec:
    host: sandbox | gateway | node    # where exec runs (default: sandbox)
    security: deny | allowlist | full # security mode (default: deny in sandbox)
    ask: off | on-miss | always       # approval ask mode (default: on-miss)
    askFallback: deny | allowlist     # on timeout behavior (default: deny)
    strictInlineEval: false           # require approval for python -c / node -e
    autoAllowSkills: false            # auto-allow registered skill binaries
    pathPrepend: []                   # paths prepended to PATH in exec env
    safeBins:                         # auto-allowed stdin-only filter tools
      - cut
      - uniq
      - head
      - tail
      - tr
      - wc
    safeBinTrustedDirs:               # only trust safe-bin exes from these dirs
      - /bin
      - /usr/bin
    safeBinProfiles:                  # extended profiles for non-default safe bins
      jq:
        allowedValueFlags: ["--arg", "--argjson"]
        deniedFlags: ["-f", "--argfile", "-L"]
        maxPositional: 1

Main Config — Tool Policy

tools:
  allow: []                           # global allow list (empty = all from profile)
  deny: []                            # global deny list (always enforced)

  read:
    allow: ["~/Projects/**"]
    deny: ["~/.ssh/**"]

  write:
    allow: ["~/Projects/**", "/tmp/**"]
    deny: ["/etc/**"]

  browser:
    enabled: true
    allowedOrigins: []                # empty = all allowed

  web_fetch:
    enabled: true
    allowedDomains: []                # empty = all allowed

agents:
  main:
    profile: full                     # full | coding | messaging | minimal
    tools:
      allow: ["group:fs", "exec"]
      deny: ["browser"]
    sandbox:
      enabled: true
      mode: non-main                  # off | non-main | all
      backend: docker
      alsoAllow: ["web_search"]       # extra tools for sandboxed copies

tools:
  elevated:
    enabled: true
    allowFrom: ["discord:123456789"]  # senders who can use /elevated

agents:
  list:
    - id: main
      tools:
        elevated:
          enabled: true
          allowFrom: ["id:123456789"] # per-agent override

Approvals Forwarding Config

approvals:
  exec:
    enabled: true
    mode: both                        # session | targets | both
    agentFilter: ["main"]             # which agents to forward for
    sessionFilter: ["discord"]        # which channels trigger forwarding
    timeout: 300000                   # ms to wait before askFallback (5 min)
    targets:
      - channel: slack
        to: U12345678
      - channel: telegram
        to: "987654321"
      - channel: discord
        to: "123456789012345678"

Sandboxing Config

sandboxing:
  mode: non-main                      # off | non-main | all
  backend: docker                     # docker | ssh | openShell
  scope: session                      # session | agent | shared
  workspaceAccess: rw                 # none | ro | rw
  docker:
    image: "ubuntu:22.04"
    memoryMb: 512
    cpus: 1.0
    network: none                     # none | bridge | host

CLI Flags & Environment Variables

CLI Flag Env Var Effect
--exec-security <mode> OC_EXEC_SECURITY Override exec security mode for this session
--exec-ask <mode> OC_EXEC_ASK Override ask mode for this session
--no-sandbox OC_SANDBOX=off Disable all sandboxing
--allow-tool <name> Add tool to allow list for this session
--deny-tool <name> Add tool to deny list for this session
--profile <name> OC_TOOL_PROFILE Set tool profile for the main agent

Source File Map

Concern Source File
Tool catalog & profiles src/agents/tool-catalog.ts
Tool policy composition src/agents/tool-policy.ts
Profile resolution & groups src/agents/tool-policy-shared.ts
Allow/deny pattern matching src/agents/tool-policy-match.ts
Policy pipeline src/agents/tool-policy-pipeline.ts
Sandbox tool policy src/agents/sandbox-tool-policy.ts
Exec approvals types & storage src/infra/exec-approvals.ts
Allowlist evaluation src/infra/exec-approvals-allowlist.ts
Command chain analysis src/infra/exec-approvals-analysis.ts
Safe-bin profiles src/infra/exec-safe-bin-policy-profiles.ts
Shell wrapper detection src/infra/shell-wrapper-resolution.ts
Inline command flags src/infra/shell-inline-command.ts
Exec command resolution src/infra/exec-command-resolution.ts
Approval binding & hashing src/infra/system-run-approval-binding.ts
Approval request context src/infra/system-run-approval-context.ts
Gateway approval manager src/gateway/exec-approval-manager.ts
Approval server methods src/gateway/server-methods/exec-approval.ts
Node invoke approval gate src/gateway/node-invoke-system-run-approval.ts
Approval config types src/config/types.approvals.ts
Approval config schema src/config/zod-schema.approvals.ts
Policy conformance / CI drift check src/agents/tool-policy.conformance.ts
Sandbox tool policy constants sandbox/constants.ts
Plugin allowlist safety src/agents/pi-tools.policy.ts
Subagent tool policy src/agents/pi-tools.policy.tsresolveSubagentToolPolicy()
Elevated execution docs/tools/elevated.md

17Design Principles

The tool permission system is built on 10 explicit design principles (source: research/00-tool-permission-overview.md appendix):

# Principle What it means in practice
1 Deny wins A tool in both allow and deny lists is blocked. No allow can override a deny at the same layer.
2 Empty allow = allow all An absent allow list means no additional filtering — not "deny everything". Only an explicit allow list restricts.
3 Plugin protection stripPluginOnlyAllowlist() prevents operators from accidentally blocking all core tools when trying to allowlist plugins.
4 Fail closed Unknown commands, unrecognized flags, and unresolvable paths default to deny. No behavior is assumed safe.
5 Chain full coverage Every segment in a chained command (&&, ||, ;, |) must independently satisfy the allowlist. One bad segment fails the whole chain.
6 Source tracing Each allowlist decision records its source ("agent", "global", "default") and config path string for auditability.
7 Layered AND semantics All policy layers are applied in sequence. A tool must pass every layer — passing one layer does not exempt it from the next.
8 Idempotent approval register() is idempotent; consumeAllowOnce() is atomic. Duplicate approvals and replay attacks are structurally prevented.
9 Permission isolation Subagents cannot inherit more permissions than their parent. Tool policy cannot be escalated through spawning.
10 Subagent least privilege DENY_ALWAYS and DENY_LEAF lists exist precisely because subagents do not need session management, memory, or gateway access by default.