Shell Execution on Bots

Turn any channel bot into a shell-capable agent with per-platform approval routing — one YAML flag

Flip one YAML flag and any inbound channel bot can run shell commands, with approval routed to the platform of your choice.

Diagram

Gateway boot

true

false

channel default

gateway

http

webhook

nothing resolves

📄 channels.slack.allow_shell: true

🔍 auto_approve_shell?

⚡ AutoApproveBackend

🧭 approval_mode?

💬 Slack/Telegram/Discord Approval

📥 GatewayApprovalBackend

🌐 HTTPApproval

🔗 WebhookApproval

⛔ CallbackBackend
deny shell commands

Before this feature, adding execute_command to an inbound bot meant building the agent, attaching an approval backend, and lifting the default deny — all in Python. Now the operator flips one flag and the gateway wires everything at bot construction.

Quick Start

  1. Turn it on

    Add allow_shell: true to any channel. On a loopback-bound gateway with a single-operator DM, auto-approve every command:

    yaml
    gateway:
      bind_host: "127.0.0.1"
    
    channels:
      slack:
        token: "${SLACK_BOT_TOKEN}"
        app_token: "${SLACK_APP_TOKEN}"
        allow_shell: true
        auto_approve_shell: true   # kept: loopback bind + no group_policy

    Start the gateway:

    bash
    praisonai gateway start

    Now @your-bot run ls executes on the server.

  2. Require approval for real workloads

    Turn off auto-approve and route the prompt to the owner. Every shell call now sends a Slack Block Kit approval message to UOWNER, and only UOWNER can approve:

    yaml
    channels:
      slack:
        token: "${SLACK_BOT_TOKEN}"
        app_token: "${SLACK_APP_TOKEN}"
        allow_shell: true
        auto_approve_shell: false
        approval_channel: "UOWNER"
        approval_users: "UOWNER"

Exposure-aware auto-approval

auto_approve_shell: true runs every shell call immediately only when the gateway is loopback-bound and the channel is a single-operator DM. On any exposed or multi-user surface it is silently downgraded to an approval backend — the exact wiring auto_approve_shell: false produces.

Diagram

No

Yes

Yes

No

Yes

No

🛡️ auto_approve_shell: true

🌐 Gateway bound
to loopback?

🔐 acknowledge_exposed
= true?

👥 group_policy
set?

✅ AutoApproveBackend

⚠️ Downgrade →
Approval backend
WARN logged

This mirrors the gateway auth token’s “calibrated by exposure” posture (assert_external_bind_safe): blanket auto-approve is only trusted where a single operator reaches the bot over loopback.

Even with auto-approval, a bot-exposed execute_command does not see OPENAI_API_KEY, GATEWAY_AUTH_TOKEN, or other _TOKEN/_SECRET/*_PASSWORD names in its environment, so env or echo $… cannot print them — the shell tool strips them from the subprocess env. See Credential Environment Variables.

Behaviour matrix

Gateway bindChannel group_policyauto_approve_shell_acknowledge_exposedEffective backend
loopback (127.0.0.1 / ::1)unset (DM / non-group)—Blanket auto-approve (AutoApproveBackend) — unchanged behaviour
externally-bound (0.0.0.0 or a real interface)anyfalse (default)Downgraded to approval backend (Slack / Telegram / Discord / HTTP / gateway approval — same routing as auto_approve_shell: false)
loopbackany group policy (mention_only, command_only, all, …)false (default)Downgraded to approval backend
externally-bound or group channelanytrueBlanket auto-approve (explicit opt-in — WARN not emitted)
bind host unknown / unresolvable——Fail closed — treat as exposed, downgrade to approval

The downgrade routes through the same approval-backend precedence documented under Choosing an approval_mode (Slack / Telegram / Discord / HTTP / gateway → fail-closed CallbackBackend). No new backend, no new precedence — only the trigger changes. Watch for this WARN line in gateway logs:

Channel 'slack' enables shell on an exposed/multi-user surface;
downgrading auto_approve_shell to require approval. Set
auto_approve_shell_acknowledge_exposed: true to keep blanket auto-approval.

Configurations

Safe default (loopback DM)

Unchanged behaviour — loopback bind, no group policy, blanket auto-approve kept.

yaml
gateway:
  bind_host: "127.0.0.1"

channels:
  slack:
    token: "${SLACK_BOT_TOKEN}"
    app_token: "${SLACK_APP_TOKEN}"
    allow_shell: true
    auto_approve_shell: true   # kept: loopback bind + no group_policy

Exposed — downgraded automatically

Externally-bound gateway on a group channel — auto_approve_shell: true silently becomes “approval required”.

yaml
gateway:
  bind_host: "0.0.0.0"

channels:
  slack:
    token: "${SLACK_BOT_TOKEN}"
    app_token: "${SLACK_APP_TOKEN}"
    allow_shell: true
    auto_approve_shell: true   # silently becomes "approval required"
    # WARN in gateway log:
    #   Channel 'slack' enables shell on an exposed/multi-user surface;
    #   downgrading auto_approve_shell to require approval. Set
    #   auto_approve_shell_acknowledge_exposed: true to keep blanket auto-approval.

Exposed — acknowledged

Explicit acknowledgement keeps blanket auto-approve on an exposed surface. No WARN is emitted.

yaml
gateway:
  bind_host: "0.0.0.0"

channels:
  slack:
    token: "${SLACK_BOT_TOKEN}"
    app_token: "${SLACK_APP_TOKEN}"
    allow_shell: true
    auto_approve_shell: true
    auto_approve_shell_acknowledge_exposed: true   # I accept the RCE surface

Configuration Options

Nine per-channel fields control shell execution and approval routing.

FieldTypeDefaultDescription
allow_shellboolFalseOpt in shell execution for this channel. Adds execute_command to the agent’s tools, injects a permission-grant sentence into instructions, and lifts the deny-list entry.
auto_approve_shellboolTrueRuns every shell call immediately (AutoApproveBackend) only on a loopback-bound gateway + non-group channel. On externally-bound gateways or any channel with a group_policy, the gateway downgrades this to the approval backend (see Exposure-aware auto-approval and auto_approve_shell_acknowledge_exposed). If False, always wires a human-in-the-loop backend via the routing ladder below.
auto_approve_shell_acknowledge_exposedboolFalseExplicit acknowledgement that blanket auto-approval is safe on this externally-exposed or multi-user channel. Set to true (with auto_approve_shell: true) to keep blanket auto-approve on 0.0.0.0 binds or group channels. Off by default — the gateway fails closed to approval.
approval_channelstrNoneWhere the approval prompt is delivered. Slack: user/channel ID. Telegram: chat ID. Discord: channel ID (falls back to home_channel).
approval_users`str \list[str]`NoneApprover allowlist. Comma-separated string or list. Falls back to SLACK_APPROVERS / TELEGRAM_APPROVERS / DISCORD_APPROVERS env var.
approval_modestrNone (= "channel")Backend selector: "channel" (default per-platform), "gateway", "http", "webhook". Validated at load time — typos throw ValueError.
approval_webhook_urlstrNoneHTTPS URL for approval_mode=webhook. Falls back to APPROVAL_WEBHOOK_URL env var. If missing, warns and falls through to GatewayApprovalBackend.
approval_http_hoststr"127.0.0.1"HTTP dashboard host for approval_mode=http. Set to "0.0.0.0" to expose.
approval_http_portint8899HTTP dashboard port for approval_mode=http.

auto_approve_shell accepts string truthy values "1", "true", "yes", "on" (case-insensitive).


Choosing an approval_mode

Pick the backend that matches where you want to answer approval prompts.

Diagram

Yes, Slack/Telegram/Discord

No / other channels

Yes

No

Yes, HTTP UI

Yes, external system

Which approval_mode?

Same channel
replies to prompt?

channel (default)
Native Block Kit / inline keyboard

Central control
plane?

gateway
Queue in gateway control plane

Existing
dashboard?

http
Built-in HTTP approval page

webhook
Forward to your endpoint

The routing precedence, exactly as wired at bot construction:

approval_mode == "gateway"                → GatewayApprovalBackend()
approval_mode == "http"                   → HTTPApproval(host, port)
approval_mode == "webhook" OR
  approval_webhook_url is set             → WebhookApproval(webhook_url)
                                             ↳ webhook mode with NO url:
                                               warn + fall through to gateway
channel_type == "slack"    + resolved id  → SlackApproval(...)
channel_type == "telegram" + resolved id  → PresentationApprovalBackend(target=chat_id)
channel_type == "discord"  + resolved id  → DiscordApproval(...)
GatewayApprovalBackend importable         → GatewayApprovalBackend()  (fallback)
otherwise                                 → CallbackBackend (deny shell tools)
                                            + warning log

On timeout, the Slack / Telegram / Discord card edits to “⌛ Timed out — not run” instead of “❌ Denied”. The auto-wired GatewayApprovalBackend() above has no notify_url, so it sends no webhook; only a backend you build yourself with notify_url also sends a follow-up {"status": "timed_out", ...} POST — see GatewayApprovalBackend webhook payload.


Fail-closed behaviour

When auto_approve_shell: false is set but no approval backend can be wired, shell commands are denied instead of silently auto-approved.

Diagram

Yes

No

Yes

🔒 auto_approve_shell: false

🧭 Any backend
resolves?

✅ Platform / gateway / http / webhook

♻️ Stale AutoApproveBackend
or None?

⛔ CallbackBackend
denies shell tools

⚠️ warning log

apply_bot_smart_defaults() may install an AutoApproveBackend when auto_approve_tools is on. If the shell wiring then finds no SlackApproval / PresentationApprovalBackend / DiscordApproval / GatewayApprovalBackend / HTTPApproval / WebhookApproval, leaving that backend in place would auto-approve shell despite the explicit opt-out. The gateway now swaps in a deny-by-default CallbackBackend.

If the gateway cannot determine its bind interface (host unresolvable), the exposure check also fails closed and blanket auto-approve is downgraded to require approval.

SituationOutcome
A backend resolves from the routing ladderShell approval routed to that backend.
No backend resolves, existing AutoApproveBackend or NoneShell tools denied by a CallbackBackend; startup logs a warning.
Non-shell toolsStill auto-approved — the deny fence is scoped to execute_command, shell_command, and acp_execute_command.

The warning line to watch for at startup:

allow_shell with auto_approve_shell=false needs approval_channel,
approval_mode (gateway|http|webhook), or a custom approval backend on the agent
— shell commands will be denied until one is configured

With routing rules

Routing rules on a shell-enabled channel keep execute_command — routed agents are shell-enabled too, not just the default channel clone.

Diagram
Shell-enabled clone (cached)Routed agent (from _agents)GatewayBotInbound messageShell-enabled clone (cached)Routed agent (from _agents)GatewayBotInbound messagealt[Cache miss][Cache hit]chat on channel with allow_shell + routing_resolve_agent_for_routing(…)routed agent (raw, no shell)_apply_channel_shell(channel, agent)clone_for_channel()apply_bot_smart_defaults(clone, config)enable_shell_tools(…)cache in _shell_routed_agents[(ch, id(agent))]cached shell-enabled clonerun(…)

Previously a routing rule swapped in a raw agent that never saw enable_shell_tools(), so execute_command disappeared for any turn that hit a routing rule — even with allow_shell: true. The gateway now re-applies the channel’s shell setup to routed agents, and runs the full smart-defaults pipeline first.

BehaviourDetail
Smart defaults inherited_apply_channel_shell calls apply_bot_smart_defaults(clone, config) before enable_shell_tools(). Routed clones now get the same memory, tool wiring, and guardrails as top-level bots — not only the shell tool.
Routed agents opt in tooEvery routed agent on a shell-enabled channel gains execute_command, closing the silent gap. Any routing rule on that channel inherits the shell capability.
Cloned per (channel, agent)The routed agent is cloned once per channel-agent pair and cached, so a shared agent used by other channels never gains execute_command on channels that did not opt in.
Approval backend reused when compatibleA routed clone reuses the channel’s wired approval backend when the target chat matches and its approver set is the same or narrower — so routed shell approvals render on the same chat. Otherwise the clone keeps its own backend.
Reload drops the clonesHot-reloading a channel to remove allow_shell: true drops the cached shell-enabled clones — the next inbound message uses the non-shell agent.

What allow_shell changes on the agent

Setting the flag runs enable_shell_tools() on the per-channel cloned agent, after smart defaults are applied.

Diagram
ToolsAgentGatewaygateway.yamlToolsAgentGatewaygateway.yamlallow_shell: trueapply_bot_smart_defaults()enable_shell_tools()+ execute_commandinstructions += permission-grant line_perm_deny -= {execute_command, shell_command, acp_execute_command}_approval_backend = <chosen backend>approval registry.set_backend(backend, agent_name)

The five side effects, in order:

StepEffect
1. Tool injectionAdds execute_command to agent.tools if not already present.
2. Permission grantAppends You can run shell commands on the bot server using the execute_command tool. to agent.instructions (idempotent).
3. Deny-list liftRemoves execute_command, shell_command, and acp_execute_command from agent._perm_deny.
4. Approval backendSets agent._approval_backend — AutoApproveBackend() when auto-approve is on, the routed platform backend when one resolves, otherwise a deny-by-default CallbackBackend that rejects shell tools (see Fail-closed behaviour).
5. Approval registry syncMirrors the chosen backend into the global approval registry keyed by agent.name, so @require_approval tools (execute_command is decorated) honour the same policy.

Per-platform routing

Each platform resolves the approval target from a fallback chain, then builds its native backend.

ChannelResolves approval_channel fromUses backend
Slackapproval_channel → owner_user_id → SLACK_APPROVAL_CHANNELSlackApproval(token, channel, allowed_approvers)
Telegramapproval_channel → owner_user_id → TELEGRAM_CHAT_IDPresentationApprovalBackend(allowed_actors, target=chat_id) — buttons render on the channel’s own bot
Discordapproval_channel → home_channel → DISCORD_APPROVAL_CHANNELDiscordApproval(token, channel_id, allowed_approvers)
WhatsApp / email / other—GatewayApprovalBackend() (fallback)

approval_users accepts a comma-separated string or a list. An empty allowlist passes allowed_approvers=None to the platform backend, whose native default lets anyone in the channel approve.


Environment Variables

Every fallback the feature reads, in one place.

Env varPurpose
SLACK_APPROVAL_CHANNELSlack user/channel ID for approval prompts (fallback for approval_channel)
TELEGRAM_CHAT_IDTelegram chat ID for approval prompts (fallback for approval_channel)
DISCORD_APPROVAL_CHANNELDiscord channel ID for approval prompts (fallback for approval_channel)
SLACK_APPROVERSComma-separated Slack user IDs (fallback for approval_users)
TELEGRAM_APPROVERSComma-separated Telegram user IDs (fallback for approval_users)
DISCORD_APPROVERSComma-separated Discord user IDs (fallback for approval_users)
APPROVAL_WEBHOOK_URLHTTPS URL (fallback for approval_webhook_url)

Recipes

Copy-paste-ready configurations for common setups.

Slack auto-approve (dev)

Fastest to demo — every shell call runs immediately on a loopback-bound gateway with a DM channel.

yaml
gateway:
  bind_host: "127.0.0.1"

channels:
  slack:
    token: "${SLACK_BOT_TOKEN}"
    app_token: "${SLACK_APP_TOKEN}"
    allow_shell: true
    auto_approve_shell: true

On 0.0.0.0 or a channel with a group_policy, add auto_approve_shell_acknowledge_exposed: true — otherwise this downgrades to approval. See Exposure-aware auto-approval.

Slack owner-DM approval

Every command needs owner approval, and only the owner may approve.

yaml
channels:
  slack:
    token: "${SLACK_BOT_TOKEN}"
    app_token: "${SLACK_APP_TOKEN}"
    allow_shell: true
    auto_approve_shell: false
    approval_channel: "UOWNER"
    approval_users: "UOWNER"

WhatsApp gateway queue

WhatsApp has no native approval wiring, so route approvals into the gateway control plane.

yaml
channels:
  whatsapp:
    allow_shell: true
    auto_approve_shell: false
    approval_mode: gateway

Multi-channel policy

Different channels, different policies — all in one gateway.

yaml
gateway:
  bind_host: "127.0.0.1"           # loopback: Slack blanket auto-approve stays

channels:
  slack:
    token: "${SLACK_BOT_TOKEN}"
    app_token: "${SLACK_APP_TOKEN}"
    allow_shell: true
    auto_approve_shell: true          # kept only on loopback + non-group DM

  telegram:
    token: "${TELEGRAM_BOT_TOKEN}"
    allow_shell: true
    auto_approve_shell: false
    approval_channel: "123456789"     # owner chat ID

  whatsapp:
    allow_shell: true
    auto_approve_shell: false
    approval_mode: gateway            # queue in control plane

  discord:
    token: "${DISCORD_BOT_TOKEN}"
    allow_shell: true
    auto_approve_shell: false
    approval_mode: webhook
    approval_webhook_url: "https://hooks.example.com/approve"

On a 0.0.0.0 bind — or if the Slack channel carries a group_policy — the Slack line downgrades to approval unless you add auto_approve_shell_acknowledge_exposed: true. See Exposure-aware auto-approval.


HTTP dashboard approvals

Serve a built-in approval page and answer from the browser.

yaml
channels:
  telegram:
    token: "${TELEGRAM_BOT_TOKEN}"
    allow_shell: true
    auto_approve_shell: false
    approval_mode: http
    approval_http_host: "0.0.0.0"     # default "127.0.0.1"
    approval_http_port: 9000          # default 8899

User Flow

A Slack workspace owner wants their support team to type @bot run pytest -q and see results in-thread, but only after they personally approve each command. They edit gateway.yaml on the server, add allow_shell: true, auto_approve_shell: false, and approval_channel: "UOWNER" to their slack: block, then restart the gateway. From that moment, every shell request from any Slack user triggers a Block Kit approval prompt in the owner’s DMs — they tap Approve and the command runs; they tap Deny and the agent replies “That command needs approval and was denied.” No Python was written.


Troubleshooting

Bot still asks for approval with auto_approve_shell: true

Upgrade to a PraisonAI build with the approval-registry sync fix (≥ 2026-07-24). On older builds, auto_approve_shell: true attached AutoApproveBackend to the agent instance only, but execute_command is decorated with @require_approval and consults the global approval registry (often with agent_name=None) — so it kept prompting. The fix mirrors the backend into the registry keyed by agent.name, and honours mark_approved() even for critical-risk tools like execute_command.


Best Practices

Never auto-approve on a public channel

Set auto_approve_shell: true only on a loopback-bound gateway with a single-operator DM. On externally-bound gateways or group channels the gateway downgrades it to approval automatically; overriding with auto_approve_shell_acknowledge_exposed: true re-opens a remote-code-execution surface — only do so on a channel you fully trust. See Exposure-aware auto-approval.

Set approval_users explicitly

Prefer an explicit approval_users allowlist over relying on SLACK_APPROVERS / TELEGRAM_APPROVERS / DISCORD_APPROVERS env vars — it keeps the policy visible in the config and avoids env-var leakage.

Use approval_mode: gateway for channels without native wiring

WhatsApp, email, and other channels have no in-channel approval UI. Route them to approval_mode: gateway so approvals queue in the control plane instead of falling silently.

Validate the YAML before starting

Run praisonai gateway lint — the fail-closed approval_mode validator rejects typos at load time, catching chanel/webook before they strand approvals.

Watch the startup log for '— shell commands will be denied'

When auto_approve_shell: false resolves no approval backend, the gateway logs a warning and denies shell tools. If your bot suddenly refuses commands with “needs approval and was denied”, that log line lists the missing knobs — add approval_channel, set approval_mode, or wire a custom backend.

Routing rules inherit shell on shell-enabled channels

Adding allow_shell: true to a channel with routing rules grants execute_command to every routed agent on that channel, not just the default one. Keep routing rules narrow on shell-enabled channels, and remove allow_shell on reload to drop the cached shell clones.


Related