Skip to content

feat(channels): per-channel command policy for public-facing bots#2063

Merged
houko merged 3 commits into
mainfrom
feat/channel-command-policy
Apr 5, 2026
Merged

feat(channels): per-channel command policy for public-facing bots#2063
houko merged 3 commits into
mainfrom
feat/channel-command-policy

Conversation

@houko

@houko houko commented Apr 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #2059. Public-facing channel bots (e.g. a customer-facing Telegram assistant) currently expose every built-in slash command to every user — including /agent <name>, which lets any user switch the session to any agent in the system. That's a real security exposure for multi-tenant deployments.

This PR adds three new ChannelOverrides fields so operators can gate commands per channel:

Field Semantics
disable_commands: bool Nuclear switch — blocks every built-in command
allowed_commands: Vec<String> Whitelist — when non-empty, only listed names work
blocked_commands: Vec<String> Blacklist — applied when allowed_commands is empty

Precedence: disable > allowed > blocked.

Blocked command handling: a blocked /command is treated as normal user text and forwarded to the agent (same fallthrough as unknown slash commands today). Public bots then respond conversationally — no "command not allowed" error, no information leak that the command exists.

Example configs

# Public customer-facing bot: no built-in commands at all
[channels.overrides."telegram"]
disable_commands = true

# Public bot that still needs /start and /help for platform UX
[channels.overrides."telegram"]
allowed_commands = ["start", "help"]

# Admin bot that just wants to block the dangerous ones
[channels.overrides."telegram"]
blocked_commands = ["agent", "new", "reboot", "model"]

Command names are the bare tokens used by the dispatcher (no leading /).

Implementation

  • crates/librefang-types/src/config/types.rs — add the three fields with #[serde(default)] + extend Default impl. No migration needed (defaults preserve current behaviour).
  • crates/librefang-channels/src/bridge.rs — add is_command_allowed() and reconstruct_command_text() helpers, gate the two existing dispatch sites (structured ChannelContent::Command variant and text-based "/foo ..." path). Blocked structured commands are reconstructed back into text via reconstruct_command_text() before hitting the normal text pipeline.

Test plan

Automated (all passing):

  • cargo build --package librefang-channels --lib — compiles
  • cargo clippy --workspace --all-targets -- -D warnings — zero warnings
  • cargo test --workspace --lib — 3268 tests pass incl. 7 new ones:
    • test_is_command_allowed_default_allows_everything
    • test_is_command_allowed_disable_commands_blocks_all
    • test_is_command_allowed_whitelist
    • test_is_command_allowed_blacklist
    • test_is_command_allowed_precedence_disable_over_allow
    • test_is_command_allowed_precedence_allow_over_block
    • test_reconstruct_command_text

Manual (recommended before merge):

  • Spin up a Telegram bot with disable_commands = true, verify /agent admin gets an agent-generated conversational reply instead of switching agents
  • Set allowed_commands = ["start", "help"], verify /start works but /new falls through to the agent
  • Set blocked_commands = ["agent"], verify /agent is blocked but /help still opens the built-in help

Closes #2059.

Add three new fields to ChannelOverrides so operators can gate the
built-in slash commands (/agent, /new, /reboot, /model, …) on a
per-channel basis:

- disable_commands: bool — nuclear switch, blocks everything
- allowed_commands: Vec<String> — whitelist (non-empty = only these)
- blocked_commands: Vec<String> — blacklist (applied when no whitelist)

Precedence: disable > allowed > blocked. When a command is blocked by
policy, the original text (e.g. "/agent admin") is forwarded to the
agent as a normal user message instead of being silently dropped or
returned as an error — matching the existing fallthrough for unknown
slash commands, so public-facing bots behave conversationally when a
user types something command-shaped.

The gate is applied at both dispatch sites in channels/bridge.rs: the
structured ChannelContent::Command variant (Telegram bot-command API)
and the text-based "/foo ..." path. Blocked structured commands are
reconstructed into text via reconstruct_command_text() before being
handed to the normal text pipeline.

Security motivation: before this change, any user talking to a public
Telegram bot could run /agent <name> to switch to admin/internal
agents, /new to reset sessions, /model to change the model, etc.
Operators previously had to either restrict the bot to allowed_users
(breaking the public use case) or accept the exposure.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot added the area/channels Messaging channel adapters label Apr 5, 2026
Add coverage of the new disable_commands / allowed_commands /
blocked_commands fields in both the channels sub-page and the main
configuration reference, in English and Chinese. Include three
recipes (locked-down public bot, public bot with start/help only,
admin bot blocking dangerous commands) plus a note that blocked
commands are forwarded to the agent as plain text rather than
rejected with an error.
@github-actions github-actions Bot added the area/docs Documentation and guides label Apr 5, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Accept both "agent" and "/agent" in allowed_commands / blocked_commands
so users don't get bitten by a silently-unused entry when they write
the slash out of habit. Add a unit test covering both forms, and
tighten the docs.
@houko
houko merged commit cba3919 into main Apr 5, 2026
14 checks passed
@houko
houko deleted the feat/channel-command-policy branch April 5, 2026 00:33
houko added a commit that referenced this pull request May 30, 2026
… channels (#5931)

* feat(channels): port command-policy and message coalescing to sidecar channels

Ports two per-channel overrides that the in-process Telegram adapter had
but the sidecar architecture dropped (#5241), so sidecar operators can
replicate them again (closes #5841).

disable_commands / allowed_commands / blocked_commands command policy is
re-exposed on `[[sidecar_channels]]` as `command_policy` (allow / disable
/ allowlist / blocklist) plus `allowed_commands` / `blocked_commands`.
A rejected command is forwarded to the agent as plain text, never
answered with an "unknown command" error — matching the #2063 behaviour
public-facing bots rely on.

message_coalesce_window_ms re-exposes the inbound debounce window (#4441):
messages from one sender arriving within the window are buffered and
dispatched as a single batched context, so a user forwarding several
messages in rapid succession produces one agent invocation instead of
racing concurrent ones on the same session.

Implementation reuses the gating the bridge already has. `SidecarAdapter`
builds a `ChannelOverrides` from its `[[sidecar_channels]]` block and
exposes it via a new `ChannelAdapter::channel_overrides()` default trait
method; the bridge prefers that per-instance override over the
kernel-level channel-type lookup, which cannot tell two sidecars sharing
a `channel_type` (e.g. two Telegram bots) apart. `command_policy` maps
onto the existing `is_command_allowed` boolean/list fields and
`message_coalesce_window_ms` onto the debouncer's `message_debounce_ms`,
so no Python sidecar change is needed — gating happens uniformly
kernel-side for every adapter type.

Tests:
- librefang-types: serde defaults are backward-compatible (allow /
  coalescing-off); disable / allowlist / blocklist parse.
- librefang-channels: `overrides_from_sidecar_config` maps each policy
  variant and the coalesce window correctly, returns None for a plain
  config, and the adapter surfaces the built overrides.

Verified in the sanctioned Docker dev image (named volumes, host target
untouched):
- cargo test -p librefang-types -p librefang-channels --lib (1333 pass)
- cargo clippy -p librefang-types -p librefang-channels --all-targets -- -D warnings (clean)
- cargo fmt -p librefang-types -p librefang-channels -- --check (clean)

The kernel-config golden fixture was updated by hand (the regen test
lives in librefang-api, whose build hits a pre-existing aarch64-Linux
`libc::SYS_getpgrp` break in librefang-runtime unrelated to this change;
the golden assertion is currently `#[ignore]`d on main for separate
pre-existing drift, so CI does not gate on it yet).

* fix(channels): fail closed when allowlist policy has no commands

`command_policy = "allowlist"` is an explicit default-deny intent, but with an empty (omitted or `[]`) `allowed_commands` the sidecar produced `ChannelOverrides { allowed_commands: [] }`.
The bridge's `is_command_allowed` treats an empty allowlist as "no allowlist configured" and falls through to the empty-blocklist branch, which allows everything — so selecting allowlist silently became fail-open, the exact public-bot scenario the policy is meant to lock down.

Map an empty allowlist onto `disable_commands = true` (the highest-precedence deny `is_command_allowed` honours) so it denies all commands.
A non-empty allowlist still allows only its listed commands; `disable` and `blocklist` are unchanged.
An empty blocklist keeps its intuitive "block nothing, allow all" meaning but now logs a WARN since it usually signals a forgotten list.

Tests:
- sidecar unit test asserts allowlist + empty list yields `disable_commands == true`.
- new bridge integration test builds the override through the real `SidecarAdapter` and drives `/agent coder` through the live dispatch path, asserting the command is gated (forwarded to the agent as the plain text `/agent coder`, router unchanged) rather than honoured.

Verified via the repo Docker dev image: `cargo test -p librefang-channels` (479 lib + 28 integration + 4 conformance + 3 doctests, all pass) and `cargo clippy -p librefang-channels --all-targets -- -D warnings` clean.

---------

Co-authored-by: Evan <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters area/docs Documentation and guides

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: per-channel command disable / whitelist for public-facing bots

1 participant