Advanced Exec Approvals: Safe Bins, Binding, Forwarding
Covers safeBins fast-path, interpreter binding, and forwarding approvals to chat channels. For users needing advanced exec approval configuration.
Read this when
- Configuring safe bins or custom safe-bin profiles
- Forwarding approvals to Slack/Discord/Telegram or other chat channels
- Implementing a native approval client for a channel
Advanced topics for exec approvals cover the safeBins fast-path, how interpreters and runtimes get bound, and forwarding approvals to chat channels, including native delivery. The core policy and approval flow are documented in Exec approvals.
Safe bins (stdin-only)
tools.exec.safeBins designates stdin-only binaries, such as cut, which operate in allowlist mode even when no explicit allowlist entries exist. These safe binaries reject positional file arguments and path-like tokens, restricting them to the input stream only. Consider this a limited fast-path meant for stream filters, not a broad trust mechanism.
Warning
Refrain from adding interpreter or runtime binaries, like
python3,node,ruby,bash,sh, orzsh, tosafeBins. When a command can evaluate code, run subcommands, or read files by design, explicit allowlist entries are the better choice, and approval prompts should stay active. Custom safe binaries must specify an explicit profile intools.exec.safeBinProfiles.<bin>.
Default safe binaries:
cut, uniq, head, tail, tr, wc
Neither grep nor sort appears in the default list. If you choose to enable them, maintain explicit allowlist entries for workflows that do not rely on stdin. For grep in safe-bin mode, supply the pattern using -e or --regexp; the positional pattern form is disallowed to prevent file operands from being passed as ambiguous positionals.
Argv validation and denied flags
Validation relies solely on the argv shape, with no checks for host filesystem existence, which avoids file-existence oracle behavior caused by differences between allow and deny outcomes. Default safe binaries have file-oriented options denied; long options validate fail-closed, rejecting unknown flags and ambiguous abbreviations. Recognized read-only boolean flags of the default binaries, such as wc -l, tr -d, and uniq -c, are allowed, while unrecognized short flags remain fail-closed and trigger manual approval.
Denied flags per safe-bin profile:
grep:--dereference-recursive,--directories,--exclude-from,--file,--recursive,-R,-d,-f,-rjq:--argfile,--from-file,--library-path,--rawfile,--slurpfile,-L,-fsort:--compress-program,--files0-from,--output,--random-source,--temporary-directory,-T,-otail:--follow,--retry,-F,-fwc:--files0-from
For stdin-only segments, safe bins additionally enforce that argv tokens are handled as literal text during execution, with no globbing and no $VARS expansion. This prevents patterns like * or $HOME/... from being exploited to smuggle file reads. Because their behavior cannot be verified as stdin-only, awk, sed, and jq are permanently excluded from safe bins: jq has the ability to access environment data and pull jq code from modules or startup files. For these tools, rely on an explicit allowlist entry or an approval prompt instead of safeBins.
Trusted binary directories
Resolution of safe bins must come from trusted binary directories, which include system defaults plus any optional tools.exec.safeBinTrustedDirs. Entries in PATH are never auto-trusted. The default trusted directories are kept deliberately sparse: /bin, /usr/bin. When your safe-bin executable resides in package-manager or user paths, such as /opt/homebrew/bin, /usr/local/bin, /opt/local/bin, /snap/bin, you must add those locations explicitly to tools.exec.safeBinTrustedDirs.
Shell chaining, wrappers, and multiplexers
Shell chaining, via &&, ||, or ;, is permitted as long as every top-level segment meets the allowlist criteria, covering safe bins or skill auto-allow. Redirections are still not supported in allowlist mode. Command substitution, whether through $() or backticks, is disallowed during allowlist parsing, even when it appears inside double quotes; opt for single quotes if you need literal $() text.
On macOS companion-app approvals, raw shell text that includes shell control or expansion syntax (&&, ||, ;, |, `, $, <, >, (, )) counts as an allowlist miss unless the shell binary itself is on the allowlist.
For shell wrappers (bash|sh|zsh ... -c/-lc), request-scoped environment overrides get narrowed to a compact explicit allowlist (TERM, LANG, LC_*, COLORTERM, NO_COLOR, FORCE_COLOR).
When making allow-always decisions in allowlist mode, transparent dispatch wrappers (for instance env, flock, nice, nohup, stdbuf, timeout) keep the inner executable path rather than the wrapper path. Shell multiplexers (busybox, toybox) get unwrapped for shell applets (sh, ash, etc.) in the same manner. If a wrapper or multiplexer cannot be safely unwrapped, no allowlist entry gets persisted on its own.
When you allowlist interpreters such as python3 or node, go with tools.exec.strictInlineEval=true so that inline eval still demands an explicit approval. Under strict mode, allow-always can still persist benign interpreter/script invocations, but inline-eval carriers are never persisted automatically.
Safe bins versus allowlist
| Topic | tools.exec.safeBins | Allowlist (SQLite exec approvals document) |
|---|---|---|
| Goal | Auto-allow narrow stdin filters | Explicitly trust specific executables |
| Match type | Executable name + safe-bin argv policy | Resolved executable path glob, or bare command-name glob for PATH-invoked commands |
| Argument scope | Restricted by safe-bin profile and literal-token rules | Path match by default; optional argPattern can restrict parsed argv |
| Typical examples | head, tail, tr, wc | jq, python3, node, ffmpeg, custom CLIs |
| Best use | Low-risk text transforms in pipelines | Any tool with broader behavior or side effects |
Where settings live:
safeBinsoriginates from config (tools.exec.safeBinsor per-agentagents.entries.*.tools.exec.safeBins).safeBinTrustedDirsoriginates from config (tools.exec.safeBinTrustedDirsor per-agentagents.entries.*.tools.exec.safeBinTrustedDirs).safeBinProfilesoriginates from config (tools.exec.safeBinProfilesor per-agentagents.entries.*.tools.exec.safeBinProfiles). Per-agent profile keys take precedence over global ones.- allowlist entries are stored in the host-local approvals document under
agents.<id>.allowlist(or via Control UI /openclaw approvals allowlist ...). openclaw security auditemits a warning withtools.exec.safe_bins_interpreter_unprofiledwhen interpreter/runtime bins show up insafeBinswithout explicit profiles.openclaw doctor --fixcan generate missing customsafeBinProfiles.<bin>entries as{}(review and tighten afterward). Interpreter/runtime bins are not auto-generated.
Custom profile example:
{
tools: {
exec: {
safeBins: ["myfilter"],
safeBinProfiles: {
myfilter: {
minPositional: 0,
maxPositional: 0,
allowedValueFlags: ["-n", "--limit"],
deniedFlags: ["-f", "--file", "-c", "--command"],
},
},
},
},
}
Interpreter/runtime commands
Approval-backed interpreter and runtime executions deliberately play it safe:
- The exact argv, cwd, and env context is always pinned down.
- Direct shell script and direct runtime file invocations are pinned to a single concrete local file snapshot on a best-effort basis.
- Common package-manager wrapper forms that still collapse to one direct local file (for instance
pnpm exec,pnpm node,npm exec,npx) are unwrapped prior to binding. - When OpenClaw cannot pin an interpreter or runtime command to exactly one concrete local file (such as package scripts, eval forms, runtime-specific loader chains, or ambiguous multi-file cases), approval-backed execution is refused rather than pretending to offer semantic coverage it lacks.
- For those situations, prefer sandboxing, a separate host boundary, or an explicit trusted allowlist or full workflow where the operator accepts the broader runtime semantics.
When approvals are mandatory, the exec tool responds immediately with an approval id. Correlate
later approved-run system events with that id (Exec finished, and Exec running when configured).
If no decision arrives before the timeout, the request counts as an approval timeout and is
reported as a terminal host-command denial. For main-agent async approvals tied to an originating
session, OpenClaw also resumes that session with an internal followup so the agent sees that the
command never ran, rather than patching up a missing result later. Pending exec approvals expire
after 30 minutes by default.
Followup delivery behavior
Once an approved async exec finishes, OpenClaw sends a followup agent turn to the same session.
Denied async approvals follow the same main-session followup path for the denial status, but they
register no elevated runtime handoffs and execute nothing. Denials without a resumable main session
are either suppressed or delivered through a safe direct route when one exists.
- With a valid external delivery target in place (deliverable channel plus target
to), followup delivery goes through that channel. - In webchat-only or internal-session flows lacking an external target, followup delivery remains session-only (
deliver: false). - If a caller explicitly demands strict external delivery and no external channel can be resolved, the request fails with
INVALID_REQUEST. - When
bestEffortDeliveris on and no external channel can be resolved, delivery drops to session-only instead of failing.
Minimal scopes for third-party clients
Gateway approval resolution sits behind the dedicated operator.approvals scope. That applies to both the
owner-specific exec.approval.resolve method and the kind-agnostic approval.resolve method; operator.write does not
cover it. Dashboards and integrations should ask for only the scopes their methods actually need.
Treat approval-resolution access as remote-execution-grade authority and hand out operator.approvals
deliberately, even when the client shows only a small approval UI.
Approval forwarding to chat channels
Exec approval prompts can be forwarded to any chat channel, plugin channels included, and approved
with /approve. The standard outbound delivery pipeline handles this.
Config:
{
approvals: {
exec: {
enabled: true,
mode: "session", // "session" | "targets" | "both"
agentFilter: ["main"],
sessionFilter: ["discord"], // substring or regex
targets: [
{ channel: "slack", to: "U12345678" },
{ channel: "telegram", to: "123456789" },
],
},
},
}
Reply in chat:
/approve <id> allow-once
/approve <id> allow-always
/approve <id> deny
The /approve command covers both exec approvals and plugin approvals. If the ID matches no pending
exec approval, it checks plugin approvals automatically. That fallback is limited to "approval not
found" failures; a real exec approval denial or error never silently retries as a plugin approval.
Plugin approval forwarding
Plugin approval forwarding reuses the exec approval delivery pipeline but carries its own separate
config under approvals.plugin. Toggling one leaves the other untouched.
For plugin-authoring behavior, request fields, and decision semantics, see
Plugin permission requests.
{
approvals: {
plugin: {
enabled: true,
mode: "targets",
agentFilter: ["main"],
targets: [
{ channel: "slack", to: "U12345678" },
{ channel: "telegram", to: "123456789" },
],
},
},
}
The config shape matches approvals.exec exactly: enabled, mode, agentFilter,
sessionFilter, and targets behave identically.
Channels with shared interactive replies render the same approval buttons for exec and plugin
approvals alike. Channels lacking shared interactive UI fall back to plain text with /approve
instructions. Plugin approval requests can restrict the available decisions: approval surfaces use
the request's declared decision set, and the Gateway rejects any attempt to submit a decision that
was not offered.
Same-chat approvals on any channel
When an exec or plugin approval request starts from a deliverable chat surface, that same chat can
approve it with /approve by default. This covers Slack, Matrix, Microsoft Teams, and similar
deliverable chats, on top of the existing Web UI and terminal UI flows, using the normal channel
auth model for that conversation. If the originating chat can already send commands and receive
replies, approval requests no longer need a separate native delivery adapter just to stay pending.
Discord, Telegram, and QQ bot also support same-chat /approve, but those channels still use their
resolved approver list for authorization even when native approval delivery is off.
Native approval delivery
Some channels can serve as native approval clients as well: Discord, Slack, Telegram, Matrix, and
QQ bot. Native clients layer approver DMs, origin-chat fanout, and channel-specific interactive
approval UX on top of the shared same-chat /approve flow.
When native approval cards or buttons are available, that native UI is the primary agent-facing
path. The agent should not also echo a duplicate plain chat /approve command unless the tool
result says chat approvals are unavailable or manual approval is the only remaining path.
If a native approval client is configured but no native runtime is active for the originating
channel, OpenClaw keeps the local deterministic /approve prompt visible. If the native runtime is
active and attempts delivery but no target receives the card, OpenClaw sends a same-chat fallback
notice with the exact /approve <id> <decision> command so the request can still be resolved.
Generic model:
- host exec policy still decides whether exec approval is required
approvals.execcontrols forwarding approval prompts to other chat destinationschannels.<channel>.execApprovalscontrols whether Discord, Slack, Telegram, QQ bot, and similar channel-specific native clients are enabled- Slack plugin approvals can use Slack's native approval client when the request comes from Slack
and Slack plugin approvers resolve;
approvals.plugincan also route plugin approvals to Slack sessions or targets even when Slack exec approvals are disabled - Google Chat native approval cards handle exec and plugin approvals that originate from Google
Chat spaces or threads when stable
users/<id>approvers resolve fromdm.allowFromordefaultTo; they do not use reaction events for decisions - WhatsApp and Signal reaction approval delivery are gated by
approvals.execandapprovals.plugin; they do not havechannels.<channel>.execApprovalsblocks
Native approval clients auto-enable DM-first delivery when all of these are true:
- the channel supports native approval delivery
- approvers can be resolved from explicit
execApprovals.approversor owner identity such ascommands.ownerAllowFrom channels.<channel>.execApprovals.enabledis unset or"auto"
Set enabled: false to turn off a native approval client explicitly. Use enabled: true to compel it on when approvers are resolved. Public origin-chat delivery remains explicit through channels.<channel>.execApprovals.target. When native target turns on origin-chat delivery, approval prompts include the command text.
FAQ: Why are there two exec approval configs for chat approvals?
- Discord:
channels.discord.execApprovals.* - Slack:
channels.slack.execApprovals.* - Telegram:
channels.telegram.execApprovals.* - QQ bot:
channels.qqbot.execApprovals.* - Google Chat: set up stable approvers with
channels.googlechat.dm.allowFromorchannels.googlechat.defaultTo; noexecApprovalsblock is needed - WhatsApp: use
approvals.execandapprovals.pluginto send approval prompts to WhatsApp - Signal: use
approvals.execandapprovals.pluginto send approval prompts to Signal
Routing specific to native clients:
- Telegram defaults to approver DMs (
target: "dm"). Change tochannelorbothto display approval prompts in the originating Telegram chat or topic as well. For Telegram forum topics, OpenClaw keeps the topic for both the approval prompt and the follow-up after approval. - Discord and Telegram approvers can be explicit (
execApprovals.approvers) or derived fromcommands.ownerAllowFrom; only resolved approvers can approve or deny. - Slack approvers can be explicit (
execApprovals.approvers) or derived fromcommands.ownerAllowFrom. Slack plugin approval DMs use Slack plugin approvers fromallowFromand account default routing, not Slack exec approvers. Slack native buttons keep the approval id kind, soplugin:ids can resolve plugin approvals without a second Slack-local fallback layer. - Google Chat native cards keep the manual
/approvefallback in message text, but card button callbacks carry only opaque action tokens; the approval id and decision come from server-side pending state. - WhatsApp emoji approvals handle both exec and plugin prompts when the matching top-level forwarding family routes to WhatsApp. Native-origin prompts bind directly; shared target-mode delivery binds the same typed approval metadata to the accepted WhatsApp message receipt.
- Signal reaction approvals handle both exec and plugin prompts only when the matching top-level forwarding family is enabled and routes to Signal. Direct same-chat Signal exec approvals can suppress the local
/approvefallback without explicit approvers; Signal reaction resolution still requires explicit Signal approvers fromchannels.signal.allowFromordefaultTo. - Matrix native DM or channel routing and reaction shortcuts handle both exec and plugin approvals; plugin authorization still comes from
channels.matrix.dm.allowFrom. Matrix native prompts includecom.openclaw.approvalcustom event content on the first prompt event so OpenClaw-aware Matrix clients can read structured approval state while stock clients keep the plain-text/approvefallback. - Native Discord and Telegram approval buttons carry an explicit exec or plugin owner kind in transport-private callback data and resolve only that owner. Older
/approvecontrols that lack a kind remain a bounded compatibility path: they try only owner kinds the actor may approve, continue only after an approval-not-found result, and never infer ownership from the approval ID. - The requester does not need to be an approver.
- If no operator UI or configured approval client can accept the request, the prompt falls back to
askFallback.
Sensitive owner-only group commands such as /diagnostics and /export-trajectory use private owner routing for approval prompts and final results. OpenClaw first tries a private route on the same surface where the owner ran the command. If that surface has no private owner route, it falls back to the first available owner route from commands.ownerAllowFrom, so a Discord group command can still send the approval and result to the owner's Telegram DM when Telegram is the configured primary private interface. The group chat only gets a short acknowledgement.
See:
Official mobile operator apps
The official iOS and Android apps can also review Gateway-owned pending exec approvals when an operator.admin connection is used, or when their paired operator.approvals device was explicitly targeted by the request. They read the same sanitized durable record used by the Control UI, submit a kind-aware decision, and display the Gateway's canonical first-answer result. The Apple Watch mirrors these approval prompts through the paired iPhone, with allow-once and deny actions. Direct Watch Gateway mode does not review approvals.
A lost resolve acknowledgement does not make the submitted choice authoritative: the app disables the controls and reads the record again. If another surface won, the app shows that recorded decision. Pending prompts remain bound to the Gateway that issued them, so switching the active Gateway cannot redirect an old approval ID.
macOS IPC flow
Gateway -> Node Service (WS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + approvals + system.run)
Security notes:
- Unix socket mode
0600, token stored in theexec_approvals_configrow ofstate/openclaw.sqlite. - Same-UID peer check.
- Challenge/response (nonce + HMAC token + request hash) + short TTL.
FAQ
When would accountId and threadId be used on an approval target?
Use accountId when the channel has multiple configured identities and the approval prompt must leave through one specific account. Use threadId when the destination supports topics or threads and the prompt should stay inside that thread instead of the top-level chat.
A concrete Telegram case is an operations supergroup with forum topics and two Telegram bot accounts. The to value names the supergroup, accountId selects the bot account, and threadId selects the forum topic:
{
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [
{
channel: "telegram",
to: "-1001234567890",
accountId: "ops-bot",
threadId: "77",
},
],
},
},
channels: {
telegram: {
accounts: {
default: {
name: "Primary bot",
botToken: "env:TELEGRAM_PRIMARY_BOT_TOKEN",
},
"ops-bot": {
name: "Operations bot",
botToken: "env:TELEGRAM_OPS_BOT_TOKEN",
},
},
},
},
}
With that configuration in place, forwarded exec approvals get posted by the ops-bot Telegram account into topic 77 of chat -1001234567890. A target lacking accountId falls back to the channel's default account, while a target without threadId sends its output to the top-level destination.
When approvals are sent to a session, can anyone in that session approve them?
No. Session delivery only dictates where the prompt shows up. It does not, on its own, grant approval rights to every member of that chat.
For generic same-chat /approve, the sender needs to already hold command authorization within that channel session. When the channel defines explicit approval approvers, those approvers can authorize the /approve action even if they lack general command authorization in that session.
Certain channels impose stricter rules. Discord, Telegram, Matrix, Slack native approval DMs, and other native approval clients rely on their resolved approver lists to determine approval authorization. As an example, a Telegram forum-topic approval prompt might be visible to all topic participants, yet only numeric Telegram user IDs derived from channels.telegram.execApprovals.approvers or commands.ownerAllowFrom are permitted to approve or reject it.
Related
- Exec approvals, fundamental policy and approval workflow
- Exec tool
- Elevated mode
- Skills, skill-driven auto-allow behavior