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, or zsh, to safeBins. 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 in tools.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, -r
  • jq: --argfile, --from-file, --library-path, --rawfile, --slurpfile, -L, -f
  • sort: --compress-program, --files0-from, --output, --random-source, --temporary-directory, -T, -o
  • tail: --follow, --retry, -F, -f
  • wc: --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

Topictools.exec.safeBinsAllowlist (SQLite exec approvals document)
GoalAuto-allow narrow stdin filtersExplicitly trust specific executables
Match typeExecutable name + safe-bin argv policyResolved executable path glob, or bare command-name glob for PATH-invoked commands
Argument scopeRestricted by safe-bin profile and literal-token rulesPath match by default; optional argPattern can restrict parsed argv
Typical exampleshead, tail, tr, wcjq, python3, node, ffmpeg, custom CLIs
Best useLow-risk text transforms in pipelinesAny tool with broader behavior or side effects

Where settings live:

  • safeBins originates from config (tools.exec.safeBins or per-agent agents.entries.*.tools.exec.safeBins).
  • safeBinTrustedDirs originates from config (tools.exec.safeBinTrustedDirs or per-agent agents.entries.*.tools.exec.safeBinTrustedDirs).
  • safeBinProfiles originates from config (tools.exec.safeBinProfiles or per-agent agents.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 audit emits a warning with tools.exec.safe_bins_interpreter_unprofiled when interpreter/runtime bins show up in safeBins without explicit profiles.
  • openclaw doctor --fix can generate missing custom safeBinProfiles.<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 bestEffortDeliver is 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.exec controls forwarding approval prompts to other chat destinations
  • channels.<channel>.execApprovals controls 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.plugin can 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 from dm.allowFrom or defaultTo; they do not use reaction events for decisions
  • WhatsApp and Signal reaction approval delivery are gated by approvals.exec and approvals.plugin; they do not have channels.<channel>.execApprovals blocks

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.approvers or owner identity such as commands.ownerAllowFrom
  • channels.<channel>.execApprovals.enabled is 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.allowFrom or channels.googlechat.defaultTo; no execApprovals block is needed
  • WhatsApp: use approvals.exec and approvals.plugin to send approval prompts to WhatsApp
  • Signal: use approvals.exec and approvals.plugin to send approval prompts to Signal

Routing specific to native clients:

  • Telegram defaults to approver DMs (target: "dm"). Change to channel or both to 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 from commands.ownerAllowFrom; only resolved approvers can approve or deny.
  • Slack approvers can be explicit (execApprovals.approvers) or derived from commands.ownerAllowFrom. Slack plugin approval DMs use Slack plugin approvers from allowFrom and account default routing, not Slack exec approvers. Slack native buttons keep the approval id kind, so plugin: ids can resolve plugin approvals without a second Slack-local fallback layer.
  • Google Chat native cards keep the manual /approve fallback 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 /approve fallback without explicit approvers; Signal reaction resolution still requires explicit Signal approvers from channels.signal.allowFrom or defaultTo.
  • 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 include com.openclaw.approval custom event content on the first prompt event so OpenClaw-aware Matrix clients can read structured approval state while stock clients keep the plain-text /approve fallback.
  • 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 /approve controls 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 the exec_approvals_config row of state/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.

3,237 words · updated Aug 6, 2026