## Summary - Adds native Google Chat approval cards for exec and plugin approval requests that originate from Google Chat spaces or threads. - Uses opaque server-side action tokens for Google Chat `cardsV2` button callbacks and updates delivered approval messages after resolution or expiry. - Preserves the shipped Google Chat typing-message default while keeping approval cards on the channel-local native path. - Suppresses duplicate manual `/approve ...` follow-up delivery inside `extensions/googlechat/` when the native card path owns the approval prompt. - Documents Google Chat native approval behavior and the `typingIndicator: "message"` default. ## Linked context Which issue does this close? Closes # Which issues, PRs, or discussions are related? Related Spec 24.8: Google Chat native approval cards. Was this requested by a maintainer or owner? Requested by maintainer in the Codex task thread. ## Real behavior proof (required for external PRs) - Behavior addressed: Google Chat exec and plugin approvals render as native cards and resolve through Google Chat button clicks. The latest change verifies an exec approval card is not accompanied by a duplicate manual `/approve` instruction bubble. - Real environment tested: OpenClaw dev profile with a real Google Chat DM to the OpenClaw app, local gateway behind a temporary Cloudflare quick tunnel, and Arc/Computer Use against the signed-in Google Chat session. - Exact steps or command run after this patch: Rebuilt the gateway runtime, started the dev-profile gateway with the Google Chat webhook routed through the tunnel, sent a fresh exec request from Google Chat, verified only the native approval card appeared, clicked `Allow Once` in Google Chat, and checked the command output reply plus marker file. - Evidence after fix (screenshot, recording, terminal capture, console output, redacted runtime log, linked artifact, or copied live output): Latest proof used nonce `GCHAT_NODOUBLE_LIVE_20260604070730`, approval id `949bc08c-9e57-47c0-b045-137603782292`, and proof directory `.mem/main/proofs/demo-89502-dev-gchat-exec-approval-no-double-send-channel-race/`. `raw/google-chat-gchat-nodouble-request-card-only-clean.png` shows the fresh user message followed by a single native `Exec Approval Required` card with `Allow Once`/`Deny` and no manual `/approve` follow-up bubble. `raw/google-chat-gchat-nodouble-resolved-clean.png` shows the card edited to `Exec Approval: Allowed once` and the final successful command reply. `raw/gchat-nodouble-live-filtered-log.txt` contains `googlechat approval resolved id=949bc08c-9e57-47c0-b045-137603782292 decision=allow-once`. `raw/marker-file-check.txt` records `/tmp/openclaw-gchat-no-double-GCHAT_NODOUBLE_LIVE_20260604070730` as created. - Observed result after fix: The approval prompt posted as a native Google Chat card only. No duplicate manual approval-instruction bubble was sent. Clicking `Allow Once` resolved the approval through the gateway and OpenClaw replied with the successful exec output in the same Google Chat DM. - What was not tested: A persistent production Google Chat app URL; live proof used a temporary Cloudflare tunnel for the local dev callback. - Proof limitations or environment constraints: Video was not captured for the final resumed manual UI run; still screenshots, gateway/proxy logs, a marker-file artifact, and Showboat verification were captured. - Before evidence (optional but encouraged): Before the final channel-local suppression path, Google Chat could show both the native approval card and a separate manual `/approve` instruction bubble. ## Tests and validation Which commands did you run? - `node scripts/build-all.mjs gatewayWatch` - `node scripts/run-vitest.mjs extensions/googlechat/src/monitor-webhook.test.ts extensions/googlechat/src/monitor.test.ts extensions/googlechat/src/monitor.reply-delivery.test.ts extensions/googlechat/src/monitor-durable.test.ts extensions/googlechat/src/approval-card-actions.test.ts extensions/googlechat/src/approval-handler.runtime.test.ts extensions/googlechat/src/approval-native.test.ts extensions/googlechat/src/approval-card-click.test.ts extensions/googlechat/src/channel-config.test.ts extensions/googlechat/src/targets.test.ts` - `git diff --check` - `pnpm docs:list` - `uvx showboat --workdir .mem/main/proofs/demo-89502-dev-gchat-exec-approval-no-double-send-channel-race verify .mem/main/proofs/demo-89502-dev-gchat-exec-approval-no-double-send-channel-race/raw/showboat-summary.md` - Live dev-profile Google Chat proof described above. What regression coverage was added or updated? - Added Google Chat native approval capability, runtime delivery, card token, and card-click resolver tests. - Added in-flight native card send suppression coverage so manual follow-up text is suppressed while native card delivery is pending. - Added cleanup coverage so manual follow-ups are restored if native card send fails. - Updated webhook ACK coverage for card-click events and default typing-indicator behavior coverage. What failed before this fix, if known? Google Chat could deliver the native approval card and still allow a model/message-tool manual `/approve` follow-up to appear as a second visible bubble. If no test was added, why not? Tests were added for the changed runtime and webhook behavior. ## Risk checklist Did user-visible behavior change? (`Yes/No`) Yes. Did config, environment, or migration behavior change? (`Yes/No`) No migration. The shipped Google Chat `typingIndicator: "message"` default is preserved. Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`) Yes. What is the highest-risk area? Approval authorization and callback token handling for native Google Chat card actions. How is that risk mitigated? Callbacks carry opaque action tokens only, token bindings check account, space, message, expiry, allowed decision, and in-flight state, and actor authorization reuses the existing Google Chat approver allowlist adapter based on stable `users/<id>` principals. ## Current review state What is the next action? Merge after current-head CI for `5923f2af46`. What is still waiting on author, maintainer, CI, or external proof? Current-head CI is green for `5923f2af46`; live dev-profile proof is complete. Which bot or reviewer comments were addressed? Addressed duplicate approval delivery by keeping the final suppression path inside `extensions/googlechat/`, preserving default typing-message behavior, and proving the current Google Chat surface sends only the native approval card.
22 KiB
summary, read_when, title
| summary | read_when | title | |||
|---|---|---|---|---|---|
| Advanced exec approvals: safe bins, interpreter binding, approval forwarding, native delivery |
|
Exec approvals — advanced |
Advanced exec-approval topics: the safeBins fast-path, interpreter/runtime
binding, and approval-forwarding to chat channels (including native delivery).
For the core policy and approval flow, see Exec approvals.
Safe bins (stdin-only)
tools.exec.safeBins defines a small list of stdin-only binaries (for
example cut) that can run in allowlist mode without explicit allowlist
entries. Safe bins reject positional file args and path-like tokens, so they
can only operate on the incoming stream. Treat this as a narrow fast-path for
stream filters, not a general trust list.
Default safe bins:
cut, uniq, head, tail, tr, wc
grep and sort are not in the default list. If you opt in, keep explicit
allowlist entries for their non-stdin workflows. For grep in safe-bin mode,
provide the pattern with -e/--regexp; positional pattern form is rejected
so file operands cannot be smuggled as ambiguous positionals.
Argv validation and denied flags
Validation is deterministic from argv shape only (no host filesystem existence checks), which prevents file-existence oracle behavior from allow/deny differences. File-oriented options are denied for default safe bins; long options are validated fail-closed (unknown flags and ambiguous abbreviations are rejected).
Denied flags by 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,-owc:--files0-from
Safe bins also force argv tokens to be treated as literal text at execution
time (no globbing and no $VARS expansion) for stdin-only segments, so patterns
like * or $HOME/... cannot be used to smuggle file reads.
Trusted binary directories
Safe bins must resolve from trusted binary directories (system defaults plus
optional tools.exec.safeBinTrustedDirs). PATH entries are never auto-trusted.
Default trusted directories are intentionally minimal: /bin, /usr/bin. If
your safe-bin executable lives in package-manager/user paths (for example
/opt/homebrew/bin, /usr/local/bin, /opt/local/bin, /snap/bin), add them
explicitly to tools.exec.safeBinTrustedDirs.
Shell chaining, wrappers, and multiplexers
Shell chaining (&&, ||, ;) is allowed when every top-level segment
satisfies the allowlist (including safe bins or skill auto-allow). Redirections
remain unsupported in allowlist mode. Command substitution ($() / backticks) is
rejected during allowlist parsing, including inside double quotes; use single
quotes if you need literal $() text.
On macOS companion-app approvals, raw shell text containing shell control or
expansion syntax (&&, ||, ;, |, `, $, <, >, (, )) is
treated as an allowlist miss unless the shell binary itself is allowlisted.
For shell wrappers (bash|sh|zsh ... -c/-lc), request-scoped env overrides are
reduced to a small explicit allowlist (TERM, LANG, LC_*, COLORTERM,
NO_COLOR, FORCE_COLOR).
For allow-always decisions in allowlist mode, known dispatch wrappers (env,
nice, nohup, stdbuf, timeout) persist the inner executable path instead
of the wrapper path. Shell multiplexers (busybox, toybox) are unwrapped for
shell applets (sh, ash, etc.) the same way. If a wrapper or multiplexer
cannot be safely unwrapped, no allowlist entry is persisted automatically.
If you allowlist interpreters like python3 or node, prefer
tools.exec.strictInlineEval=true so inline eval still requires an explicit
approval. In strict mode, allow-always can still persist benign
interpreter/script invocations, but inline-eval carriers are not persisted
automatically.
Safe bins versus allowlist
| Topic | tools.exec.safeBins |
Allowlist (exec-approvals.json) |
|---|---|---|
| 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 |
Configuration location:
safeBinscomes from config (tools.exec.safeBinsor per-agentagents.list[].tools.exec.safeBins).safeBinTrustedDirscomes from config (tools.exec.safeBinTrustedDirsor per-agentagents.list[].tools.exec.safeBinTrustedDirs).safeBinProfilescomes from config (tools.exec.safeBinProfilesor per-agentagents.list[].tools.exec.safeBinProfiles). Per-agent profile keys override global keys.- allowlist entries live in host-local
~/.openclaw/exec-approvals.jsonunderagents.<id>.allowlist(or via Control UI /openclaw approvals allowlist ...). openclaw security auditwarns withtools.exec.safe_bins_interpreter_unprofiledwhen interpreter/runtime bins appear insafeBinswithout explicit profiles.openclaw doctor --fixcan scaffold missing customsafeBinProfiles.<bin>entries as{}(review and tighten afterward). Interpreter/runtime bins are not auto-scaffolded.
Custom profile example:
{
tools: {
exec: {
safeBins: ["jq", "myfilter"],
safeBinProfiles: {
myfilter: {
minPositional: 0,
maxPositional: 0,
allowedValueFlags: ["-n", "--limit"],
deniedFlags: ["-f", "--file", "-c", "--command"],
},
},
},
},
}
If you explicitly opt jq into safeBins, OpenClaw still rejects the env builtin in safe-bin
mode so jq -n env cannot dump the host process environment without an explicit allowlist path
or approval prompt.
Interpreter/runtime commands
Approval-backed interpreter/runtime runs are intentionally conservative:
- Exact argv/cwd/env context is always bound.
- Direct shell script and direct runtime file forms are best-effort bound to one concrete local file snapshot.
- Common package-manager wrapper forms that still resolve to one direct local file (for example
pnpm exec,pnpm node,npm exec,npx) are unwrapped before binding. - If OpenClaw cannot identify exactly one concrete local file for an interpreter/runtime command (for example package scripts, eval forms, runtime-specific loader chains, or ambiguous multi-file forms), approval-backed execution is denied instead of claiming semantic coverage it does not have.
- For those workflows, prefer sandboxing, a separate host boundary, or an explicit trusted allowlist/full workflow where the operator accepts the broader runtime semantics.
When approvals are required, the exec tool returns immediately with an approval id. Use that id to
correlate later approved-run system events (Exec finished, and Exec running when configured).
If no decision arrives before the timeout, the request is treated as an approval timeout and
surfaced as a terminal host-command denial. For main-agent async approvals with an originating
session, OpenClaw also resumes that session with an internal followup so the agent observes that
the command did not run instead of later repairing a missing result.
Followup delivery behavior
After an approved async exec finishes, OpenClaw sends a followup agent turn to the same session.
Denied async approvals use the same main-session followup path for the denial status, but they do
not register elevated runtime handoffs and they do not run the command. Denials without a resumable
main session are either suppressed or reported through a safe direct route when one exists.
- If a valid external delivery target exists (deliverable channel plus target
to), followup delivery uses that channel. - In webchat-only or internal-session flows with no external target, followup delivery stays session-only (
deliver: false). - If a caller explicitly requests strict external delivery with no resolvable external channel, the request fails with
INVALID_REQUEST. - If
bestEffortDeliveris enabled and no external channel can be resolved, delivery is downgraded to session-only instead of failing.
Approval forwarding to chat channels
You can forward exec approval prompts to any chat channel (including plugin channels) and approve
them with /approve. This uses the normal outbound delivery pipeline.
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 handles both exec approvals and plugin approvals. If the ID does not match a pending exec approval, it automatically checks plugin approvals instead.
Plugin approval forwarding
Plugin approval forwarding uses the same delivery pipeline as exec approvals but has its own
independent config under approvals.plugin. Enabling or disabling one does not affect the other.
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 is identical to approvals.exec: enabled, mode, agentFilter,
sessionFilter, and targets work the same way.
Channels that support shared interactive replies render the same approval buttons for both exec and
plugin approvals. Channels without shared interactive UI fall back to plain text with /approve
instructions.
Plugin approval requests may restrict the available decisions. Approval surfaces use the request's
declared decision set, and the Gateway rejects attempts to submit a decision that was not offered.
Same-chat approvals on any channel
When an exec or plugin approval request originates from a deliverable chat surface, the same chat
can now approve it with /approve by default. This applies to channels such as Slack, Matrix, and
Microsoft Teams in addition to the existing Web UI and terminal UI flows.
This shared text-command path uses 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 and Telegram also support same-chat /approve, but those channels still use their
resolved approver list for authorization even when native approval delivery is disabled.
For Telegram and other native approval clients that call the Gateway directly, this fallback is intentionally bounded to "approval not found" failures. A real exec approval denial/error does not silently retry as a plugin approval.
Native approval delivery
Some channels can also act as native approval clients. Native clients add approver DMs, origin-chat
fanout, and channel-specific interactive approval UX on top of the shared same-chat /approve
flow.
When native approval cards/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, 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 disable a native approval client explicitly. Set enabled: true to force
it on when approvers resolve. Public origin-chat delivery stays explicit through
channels.<channel>.execApprovals.target.
FAQ: Why are there two exec approval configs for chat approvals?
- Discord:
channels.discord.execApprovals.* - Slack:
channels.slack.execApprovals.* - Telegram:
channels.telegram.execApprovals.* - Google Chat: configure stable approvers with
channels.googlechat.dm.allowFromorchannels.googlechat.defaultTo; noexecApprovalsblock is required - WhatsApp: use
approvals.execandapprovals.pluginto route approval prompts to WhatsApp - Signal: use
approvals.execandapprovals.pluginto route approval prompts to Signal
These native approval clients add DM routing and optional channel fanout on top of the shared
same-chat /approve flow and shared approval buttons.
Shared behavior:
- Slack, Matrix, Microsoft Teams, and similar deliverable chats use the normal channel auth model
for same-chat
/approve - when a native approval client auto-enables, the default native delivery target is approver DMs
- for Discord and Telegram, only resolved approvers can approve or deny
- Discord approvers can be explicit (
execApprovals.approvers) or inferred fromcommands.ownerAllowFrom - Telegram approvers can be explicit (
execApprovals.approvers) or inferred fromcommands.ownerAllowFrom - Slack approvers can be explicit (
execApprovals.approvers) or inferred fromcommands.ownerAllowFrom - Slack plugin approval DMs use Slack plugin approvers from
allowFromand account default routing, not Slack exec approvers - Slack native buttons preserve approval id kind, so
plugin:ids can resolve plugin approvals without a second Slack-local fallback layer - Google Chat native cards preserve the manual
/approvefallback in message text but card button callbacks carry only opaque action tokens; approval id and decision are recovered from server-side pending state - WhatsApp emoji approvals handle both exec and plugin prompts only when the matching top-level forwarding family is enabled and routes to WhatsApp; target-only WhatsApp forwarding stays on the shared forwarding path unless it matches the same native origin target
- 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/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.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 - the requester does not need to be an approver
- the originating chat can approve directly with
/approvewhen that chat already supports commands and replies - native Discord approval buttons route by approval id kind:
plugin:ids go straight to plugin approvals, everything else goes to exec approvals - native Telegram approval buttons follow the same bounded exec-to-plugin fallback as
/approve - when native
targetenables origin-chat delivery, approval prompts include the command text - pending exec approvals expire after 30 minutes by default
- 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.
Telegram defaults to approver DMs (target: "dm"). You can switch to channel or both when you
want approval prompts to appear in the originating Telegram chat/topic as well. For Telegram forum
topics, OpenClaw preserves the topic for the approval prompt and the post-approval follow-up.
See:
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 inexec-approvals.json. - 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 setup, forwarded exec approvals are posted by the ops-bot Telegram account into topic
77 of chat -1001234567890. A target without accountId uses the channel's default account, and
a target without threadId posts to the top-level destination.
When approvals are sent to a session, can anyone in that session approve them?
No. Session delivery only controls where the prompt appears. It does not by itself authorize every participant in that chat to approve.
For generic same-chat /approve, the sender must already be authorized for commands in that
channel session. If the channel exposes explicit approval approvers, those approvers can authorize
the /approve action even when they are not otherwise command-authorized in that session.
Some channels are stricter. Discord, Telegram, Matrix, Slack native approval DMs, and similar
native approval clients use their resolved approver lists for approval authorization. For example,
a Telegram forum-topic approval prompt can be visible to everyone in the topic, but only numeric
Telegram user IDs resolved from channels.telegram.execApprovals.approvers or
commands.ownerAllowFrom can approve or deny it.
Related
- Exec approvals — core policy and approval flow
- Exec tool
- Elevated mode
- Skills — skill-backed auto-allow behavior