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.ts —
resolveSubagentToolPolicy(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.ts — resolveSubagentToolPolicy() |
| 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. |