feat(channels): per-channel command policy for public-facing bots#2063
Merged
Conversation
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.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
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.
Deploying librefang-docs with
|
| Latest commit: |
e8b1b0d
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://065068fa.librefang-docs-web.pages.dev |
| Branch Preview URL: | https://feat-channel-command-policy.librefang-docs-web.pages.dev |
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.
This was referenced Apr 5, 2026
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ChannelOverridesfields so operators can gate commands per channel:disable_commands: boolallowed_commands: Vec<String>blocked_commands: Vec<String>allowed_commandsis emptyPrecedence:
disable>allowed>blocked.Blocked command handling: a blocked
/commandis 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
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)]+ extendDefaultimpl. No migration needed (defaults preserve current behaviour).crates/librefang-channels/src/bridge.rs— addis_command_allowed()andreconstruct_command_text()helpers, gate the two existing dispatch sites (structuredChannelContent::Commandvariant and text-based"/foo ..."path). Blocked structured commands are reconstructed back into text viareconstruct_command_text()before hitting the normal text pipeline.Test plan
Automated (all passing):
cargo build --package librefang-channels --lib— compilescargo clippy --workspace --all-targets -- -D warnings— zero warningscargo test --workspace --lib— 3268 tests pass incl. 7 new ones:test_is_command_allowed_default_allows_everythingtest_is_command_allowed_disable_commands_blocks_alltest_is_command_allowed_whitelisttest_is_command_allowed_blacklisttest_is_command_allowed_precedence_disable_over_allowtest_is_command_allowed_precedence_allow_over_blocktest_reconstruct_command_textManual (recommended before merge):
disable_commands = true, verify/agent admingets an agent-generated conversational reply instead of switching agentsallowed_commands = ["start", "help"], verify/startworks but/newfalls through to the agentblocked_commands = ["agent"], verify/agentis blocked but/helpstill opens the built-in help